Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
01070e1
initial implementation
joshua-journey-apps Oct 6, 2025
23dc8e4
Add make, remove and get uri endpoints to local storage
joshua-journey-apps Oct 7, 2025
ecb71cb
Fix exporting node and index db storage adapters
joshua-journey-apps Oct 7, 2025
029df9d
Wip add sync throttle & cache limiting
joshua-journey-apps Oct 7, 2025
958675a
Add downloading attachment test
joshua-journey-apps Oct 7, 2025
45773aa
Add user defined storage adapter path
joshuabrink Oct 27, 2025
533dfab
Refactor watch active observer into dedicated service
joshuabrink Oct 27, 2025
643289d
Add temporal units to variable name
joshuabrink Oct 28, 2025
6840fb0
Rename storage -> syncing service
joshuabrink Oct 28, 2025
d8d4ad9
Add updateHook to save file
joshuabrink Oct 28, 2025
9509aeb
Use async onUpdate callback
joshuabrink Oct 28, 2025
9ac685c
Improve comments
joshuabrink Oct 28, 2025
ce33bab
Fix closing the watch active attachments listener
joshuabrink Oct 28, 2025
704c2a8
tests(WIP:) initial node setup and few bug fixes
Oct 31, 2025
ff25b2b
tests: fixed SyncService to rely on LocalStorage and be agnostic to f…
Oct 31, 2025
fae7f27
test: workflow tests working and passing
Nov 5, 2025
eedd224
feat: introduce AttachmentErrorHandler for custom error handling in a…
Nov 5, 2025
ec1a733
feat: added archival management to AttachmentQueue via AttachmentCon…
Nov 6, 2025
f6b2343
docs: update README.md to document usage
Nov 6, 2025
4cb5753
feat: added ExpoFileSystemAdapter to the attachments package
Nov 10, 2025
7e40d26
refactor: refactored react native demo to use new attachments API
Nov 10, 2025
206bd5f
fix: expo blob compatability and attachment row mapping
joshuabrink Nov 12, 2025
e42ae45
fix: node exclusive exports
joshuabrink Nov 12, 2025
4d5a1bc
fix: linting, dead code and move active watch into start sync
joshuabrink Nov 12, 2025
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ dist
# Useful if running repository in VSCode dev container
.pnpm-store
__screenshots__
testing.db
testing.db-*
50 changes: 22 additions & 28 deletions demos/react-native-supabase-todolist/app/views/todos/edit/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ATTACHMENT_TABLE, AttachmentRecord } from '@powersync/attachments';
import { ATTACHMENT_TABLE, attachmentFromSql, AttachmentRecord } from '@powersync/attachments';
import { usePowerSync, useQuery } from '@powersync/react-native';
import { CameraCapturedPicture } from 'expo-camera';
import _ from 'lodash';
Expand All @@ -12,21 +12,7 @@ import { TODO_TABLE, TodoRecord, LIST_TABLE } from '../../../../library/powersyn
import { useSystem } from '../../../../library/powersync/system';
import { TodoItemWidget } from '../../../../library/widgets/TodoItemWidget';

type TodoEntry = TodoRecord & Partial<Omit<AttachmentRecord, 'id'>> & { todo_id: string; attachment_id: string | null };

const toAttachmentRecord = _.memoize((entry: TodoEntry): AttachmentRecord | null => {
return entry.attachment_id == null
? null
: {
id: entry.attachment_id,
filename: entry.filename!,
state: entry.state!,
timestamp: entry.timestamp,
local_uri: entry.local_uri,
media_type: entry.media_type,
size: entry.size
};
});
type TodoEntry = TodoRecord & { todo_id: string; attachment_id: string | null };

const TodoView: React.FC = () => {
const system = useSystem();
Expand Down Expand Up @@ -61,10 +47,10 @@ const TodoView: React.FC = () => {
if (completed) {
const userID = await system.supabaseConnector.userId();
updatedRecord.completed_at = new Date().toISOString();
updatedRecord.completed_by = userID;
updatedRecord.completed_by = userID!;
} else {
updatedRecord.completed_at = undefined;
updatedRecord.completed_by = undefined;
updatedRecord.completed_at = null;
updatedRecord.completed_by = null;
}
await system.powersync.execute(
`UPDATE ${TODO_TABLE}
Expand All @@ -77,9 +63,13 @@ const TodoView: React.FC = () => {
};

const savePhoto = async (id: string, data: CameraCapturedPicture) => {
if (system.attachmentQueue) {
if (system.photoAttachmentQueue) {
// We are sure the base64 is not null, as we are using the base64 option in the CameraWidget
const { id: photoId } = await system.attachmentQueue.savePhoto(data.base64!);
const { id: photoId } = await system.photoAttachmentQueue.saveFile({
data: data.base64!,
fileExtension: 'jpg',
mediaType: 'image/jpeg'
});

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

const deleteTodo = async (id: string, photoRecord?: AttachmentRecord) => {
await system.powersync.writeTransaction(async (tx) => {
if (system.attachmentQueue && photoRecord != null) {
await system.attachmentQueue.delete(photoRecord, tx);
}
await tx.execute(`DELETE FROM ${TODO_TABLE} WHERE id = ?`, [id]);
});
if (system.photoAttachmentQueue && photoRecord != null) {
await system.photoAttachmentQueue.deleteFile({
id: photoRecord.id,
updateHook: async (tx) => {
await tx.execute(`DELETE FROM ${TODO_TABLE} WHERE id = ?`, [id]);
}
});
} else {
await system.powersync.execute(`DELETE FROM ${TODO_TABLE} WHERE id = ?`, [id]);
}
};

if (isLoading) {
Expand Down Expand Up @@ -157,7 +151,7 @@ const TodoView: React.FC = () => {
<ScrollView style={{ maxHeight: '90%' }}>
{todos.map((r) => {
const record = { ...r, id: r.todo_id };
const photoRecord = toAttachmentRecord(r);
const photoRecord = attachmentFromSql(r);
return (
<TodoItemWidget
key={r.todo_id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const AppSchema = new Schema({
todos,
lists,
attachments: new AttachmentTable({
name: 'attachments',
viewName: 'attachments',
}),
});

Expand Down

This file was deleted.

74 changes: 54 additions & 20 deletions demos/react-native-supabase-todolist/library/powersync/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,38 @@ import '@azure/core-asynciterator-polyfill';

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

import { type AttachmentRecord } from '@powersync/attachments';
import {
AttachmentQueue,
type AttachmentRecord,
ExpoFileSystemAdapter,
type WatchedAttachmentItem
} from '@powersync/attachments';
import { configureFts } from '../fts/fts_setup';
import { KVStorage } from '../storage/KVStorage';
import { SupabaseRemoteStorageAdapter } from '../storage/SupabaseRemoteStorageAdapter';
import { AppConfig } from '../supabase/AppConfig';
import { SupabaseConnector } from '../supabase/SupabaseConnector';
import { AppSchema } from './AppSchema';
import { PhotoAttachmentQueue } from './PhotoAttachmentQueue';
import { AppSchema, TODO_TABLE } from './AppSchema';

const logger = createBaseLogger();
logger.useDefaults();
logger.setLevel(LogLevel.DEBUG);

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

constructor() {
this.kvStorage = new KVStorage();
this.supabaseConnector = new SupabaseConnector(this);
this.storage = this.supabaseConnector.storage;
this.supabaseConnector = new SupabaseConnector({
kvStorage: this.kvStorage,
supabaseUrl: AppConfig.supabaseUrl,
supabaseAnonKey: AppConfig.supabaseAnonKey
});

this.powersync = new PowerSyncDatabase({
schema: AppSchema,
database: {
Expand All @@ -50,17 +57,44 @@ export class System {
*/

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 };
}
const localStorage = new ExpoFileSystemAdapter();
const remoteStorage = new SupabaseRemoteStorageAdapter({
client: this.supabaseConnector.client,
bucket: AppConfig.supabaseBucket
});

return { retry: true };
this.photoAttachmentQueue = new AttachmentQueue({
db: this.powersync,
localStorage,
remoteStorage,
watchAttachments: (onUpdate) => {
this.powersync.watch(
`SELECT photo_id as id FROM ${TODO_TABLE} WHERE photo_id IS NOT NULL`,
[],
{
onResult: (result: any) => {
const attachments: WatchedAttachmentItem[] = (result.rows?._array ?? []).map((row: any) => ({
id: row.id,
fileExtension: 'jpg'
}));
onUpdate(attachments);
}
}
);
},
errorHandler: {
onDownloadError: async (attachment: AttachmentRecord, error: Error) => {
if (error.toString() === 'StorageApiError: Object not found') {
return false; // Don't retry
}
return true; // Retry
},
onUploadError: async (attachment: AttachmentRecord, error: Error) => {
return true; // Retry uploads by default
},
onDeleteError: async (attachment: AttachmentRecord, error: Error) => {
return true; // Retry deletes by default
}
}
});
}
Expand All @@ -70,8 +104,8 @@ export class System {
await this.powersync.init();
await this.powersync.connect(this.supabaseConnector, { clientImplementation: SyncClientImplementation.RUST });

if (this.attachmentQueue) {
await this.attachmentQueue.init();
if (this.photoAttachmentQueue) {
await this.photoAttachmentQueue.startSync();
}

// Demo using SQLite Full-Text Search with PowerSync.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { AttachmentRecord, RemoteStorageAdapter } from '@powersync/attachments';

export interface SupabaseRemoteStorageAdapterOptions {
client: SupabaseClient;
bucket: string;
}

/**
* SupabaseRemoteStorageAdapter implements RemoteStorageAdapter for Supabase Storage.
* Handles upload, download, and deletion of files from Supabase Storage buckets.
*/
export class SupabaseRemoteStorageAdapter implements RemoteStorageAdapter {
constructor(private options: SupabaseRemoteStorageAdapterOptions) {}

async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void> {
const mediaType = attachment.mediaType ?? 'application/octet-stream';

const { error } = await this.options.client.storage
.from(this.options.bucket)
.upload(attachment.filename, fileData, { contentType: mediaType });

if (error) {
throw error;
}
}

async downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer> {
const { data, error } = await this.options.client.storage.from(this.options.bucket).download(attachment.filename);

if (error) {
throw error;
}

return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result as ArrayBuffer);
};
reader.onerror = reject;
reader.readAsArrayBuffer(data);
});
}

async deleteFile(attachment: AttachmentRecord): Promise<void> {
const { error } = await this.options.client.storage.from(this.options.bucket).remove([attachment.filename]);

if (error) {
console.debug('Failed to delete file from Supabase Storage', error);
throw error;
}
}
}
Loading
Loading