Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 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
8db8f2a
refactor: move attachments to common package
joshuabrink Nov 20, 2025
be856e9
refactor: moved readme to attachments folder
Nov 27, 2025
c3fd550
refactor: add ExpoFileSystemAdapter for local storage management in R…
Nov 27, 2025
26722fb
fix: export IndexDBFileSystemAdapter from web package
Nov 28, 2025
3eba6f0
chore: add helper script to clean up node_modules directories in the …
Nov 28, 2025
de9130e
docs: fixed README to properly point to React Native web demo
Nov 28, 2025
20bb3a3
refactor: demos now use attachments functionality from the core libra…
Nov 28, 2025
b8dea39
refactor: migrate to using @powersync/common for shared functionality…
Nov 28, 2025
62b0bdc
fix: exclude expo file system from bundle
Nov 28, 2025
d514ef8
Merge branch 'main' into attachment-package-refactor
Nov 28, 2025
649290c
Merge branch 'main' into attachment-package-refactor
khawarizmus Nov 28, 2025
eb0a93a
fix: add missing comma in package.json dependencies
Nov 28, 2025
d179b9c
chore: update dependencies in pnpm-lock.yaml to latest versions
Nov 28, 2025
f401bc0
fix: update import paths in attachment-related files to use local mod…
Nov 28, 2025
d86799a
chore: add changeset
Nov 28, 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
9 changes: 9 additions & 0 deletions .changeset/angry-planes-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@powersync/react-native': minor
'@powersync/common': minor
'@powersync/node': minor
'@powersync/web': minor
'@powersync/attachments': patch
---

Deprecated @powersync/attachments in favor of enhanced and consistent attachment functionality built into @powersync/common and platform-specific SDKs
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-*
51 changes: 22 additions & 29 deletions demos/react-native-supabase-todolist/app/views/todos/edit/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ATTACHMENT_TABLE, AttachmentRecord } from '@powersync/attachments';
import { usePowerSync, useQuery } from '@powersync/react-native';
import { usePowerSync, useQuery, ATTACHMENT_TABLE, attachmentFromSql, AttachmentRecord } from '@powersync/react-native';
import { CameraCapturedPicture } from 'expo-camera';
import _ from 'lodash';
import * as React from 'react';
Expand All @@ -12,21 +11,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 +46,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 +62,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 +88,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 +150,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
@@ -1,5 +1,4 @@
import { AttachmentTable } from '@powersync/attachments';
import { column, Schema, Table } from '@powersync/react-native';
import { column, Schema, Table, AttachmentTable } from '@powersync/react-native';

export const LIST_TABLE = 'lists';
export const TODO_TABLE = 'todos';
Expand Down Expand Up @@ -27,9 +26,7 @@ const lists = new Table({
export const AppSchema = new Schema({
todos,
lists,
attachments: new AttachmentTable({
name: 'attachments',
}),
attachments: new AttachmentTable(),
});

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

This file was deleted.

78 changes: 55 additions & 23 deletions demos/react-native-supabase-todolist/library/powersync/system.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,36 @@
import '@azure/core-asynciterator-polyfill';

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

import { type AttachmentRecord } 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,18 +54,46 @@ 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
}
},
logger,
});
}
}
Expand All @@ -70,8 +102,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/react-native';

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