From 58c15a27872d8ba55011d19274d20135156a9110 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 29 May 2026 14:33:38 +0000 Subject: [PATCH 1/5] poc tanstackdb attachments --- .../pnpm-workspace.yaml | 4 + .../src/app/views/todo-lists/page.tsx | 24 ++- .../src/components/providers/Attachments.ts | 168 ++++++++++++++++++ .../components/providers/SystemProvider.tsx | 76 +++++++- .../src/components/widgets/ListItemWidget.tsx | 17 +- .../components/widgets/SearchBarWidget.tsx | 2 +- .../components/widgets/TodoListsWidget.tsx | 9 +- .../src/library/powersync/AppSchema.ts | 5 +- .../src/library/powersync/ListsSchema.ts | 10 +- .../library/powersync/SupabaseConnector.ts | 2 +- 10 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts diff --git a/demos/react-supabase-todolist-tanstackdb/pnpm-workspace.yaml b/demos/react-supabase-todolist-tanstackdb/pnpm-workspace.yaml index 85a821669..53a32eb0b 100644 --- a/demos/react-supabase-todolist-tanstackdb/pnpm-workspace.yaml +++ b/demos/react-supabase-todolist-tanstackdb/pnpm-workspace.yaml @@ -4,3 +4,7 @@ allowBuilds: '@journeyapps/wa-sqlite': true '@swc/core': true esbuild: true + +trustPolicyExclude: + - rollup@2.80.0 + - semver@6.3.1 \ No newline at end of file diff --git a/demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx b/demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx index ce483d240..45810147e 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx @@ -1,5 +1,5 @@ import { NavigationPage } from '@/components/navigation/NavigationPage'; -import { listsCollection, useSupabase } from '@/components/providers/SystemProvider'; +import { attachmentQueue, listsCollection, useSupabase } from '@/components/providers/SystemProvider'; import { GuardBySync } from '@/components/widgets/GuardBySync'; import { SearchBarWidget } from '@/components/widgets/SearchBarWidget'; import { TodoListsWidget } from '@/components/widgets/TodoListsWidget'; @@ -31,13 +31,21 @@ export default function TodoListsPage() { throw new Error(`Could not create new lists, no userID found`); } - // This could alternatively be synchronous and use optimistic updates - await listsCollection.insert({ - id: crypto.randomUUID(), - name, - created_at: new Date(), - owner_id: userID - }).isPersisted.promise; + await attachmentQueue.saveFileTanStack({ + // This is just random file data for this poc, this could be an image from a camera etc + data: btoa(crypto.randomUUID()), + fileExtension: 'jpg', + updateHook: async (attachmentRecord) => { + // This should happen in the same transaction as creating the attachment + listsCollection.insert({ + id: crypto.randomUUID(), + name, + created_at: new Date(), + owner_id: userID, + photo_id: attachmentRecord.id // make the association for related data + }); + } + }); }; return ( diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts new file mode 100644 index 000000000..aed6dd38c --- /dev/null +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts @@ -0,0 +1,168 @@ +import { AppSchema } from '@/library/powersync/AppSchema'; +import { + AbstractPowerSyncDatabase, + AttachmentData, + AttachmentErrorHandler, + AttachmentQueue, + AttachmentRecord, + AttachmentService, + AttachmentState, + ILogger, + IndexDBFileSystemStorageAdapter, + LocalStorageAdapter, + RemoteStorageAdapter, + WatchedAttachmentItem +} from '@powersync/web'; +import { Collection, createTransaction } from '@tanstack/db'; +import { PowerSyncTransactor } from '@tanstack/powersync-db-collection'; + +export const LocalAttachmentStoage = new IndexDBFileSystemStorageAdapter('my-app-files'); + +export const RemoteAttachmentStorage = { + async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord) { + // no-op for poc + }, + + async downloadFile(attachment: AttachmentRecord): Promise { + // no-op for poc + return new ArrayBuffer(); + }, + + async deleteFile(attachment: AttachmentRecord) { + // no-op for poc + } +}; + +/** + * This extends the default AttachmentQueue constructor params + * FIXME(powersync) we should export this type from the common SDK. + */ +type TanStackDBAttachmentQueueParams = { + db: AbstractPowerSyncDatabase; + /** + * For TanStack, we want access to the synced TanStackDB collection. + * In order to have the same relational data be set in a single transaction. + * This also allows for joining both TanStackDB collections. + */ + attachmentsCollection: Collection; + remoteStorage: RemoteStorageAdapter; + localStorage: LocalStorageAdapter; + watchAttachments: (onUpdate: (attachment: WatchedAttachmentItem[]) => Promise, signal: AbortSignal) => void; + tableName?: string; + logger?: ILogger; + syncIntervalMs?: number; + syncThrottleDuration?: number; + downloadAttachments?: boolean; + archivedCacheLimit?: number; + errorHandler?: AttachmentErrorHandler; +}; + +/** + * The PowerSync table row type + */ +type AttachmentQueueRow = (typeof AppSchema)['types']['attachments']; + +/** + * A custom extension of the PowerSyncAttachmentQueue. + * We could export something like this in the TanStackDB integration + */ +export class TanStackDBAttachmentQueue extends AttachmentQueue { + readonly powersync: AbstractPowerSyncDatabase; + readonly collection: Collection; + + constructor(params: TanStackDBAttachmentQueueParams) { + super(params); + this.powersync = params.db; + this.collection = params.attachmentsCollection; + } + + /** + * HACK: The AttachmentQueue should make this protected instead, + * in order for extensions to use it. + */ + get _attachmentService(): AttachmentService { + // This is not protected, it's private and should be protected + return this['attachmentService'] as AttachmentService; + } + + /** + * Saves a new attachment given the input data. + * Provides an updateHook which is called inside a TanStackDB transaction. + * Relational associataions with the provded attachment ID should be made in this hook. + */ + async saveFileTanStack({ + data, + fileExtension, + mediaType, + metaData, + id, + updateHook + }: { + data: AttachmentData; + fileExtension: string; + mediaType?: string; + metaData?: string; + id?: string; + // Note that this is called inside a synchronous TanStackDB transaction + // any mutations made to other collections, will be in the same transaction. + updateHook?: (attachment: AttachmentQueueRow) => Promise; + }): Promise { + const resolvedId = id ?? (await this.generateAttachmentId()); + const filename = `${resolvedId}.${fileExtension}`; + const localUri = this.localStorage.getLocalUri(filename); + const size = await this.localStorage.saveFile(localUri, data); + + const attachment: AttachmentQueueRow = { + id: resolvedId, + filename, + media_type: mediaType ?? null, + local_uri: localUri, + state: AttachmentState.QUEUED_UPLOAD, + has_synced: 0, + size, + timestamp: new Date().getTime(), + meta_data: metaData ?? null + }; + + /** + * The use the attachmentService lock to prevent potential attachment queue race conditions. + * This specicifally prevents assuming a newly watched attachment record is one to download. + * */ + await this._attachmentService.withContext(async (ctx) => { + // Create a TanStackDB transaction context, the mutation will happen later + const tanStackDBTransaction = createTransaction({ + autoCommit: false, + mutationFn: async ({ transaction }) => { + // Now we should apply the actual operations. + // We can save the attachment using dedicated APIs + await new PowerSyncTransactor({ + database: ctx.db + }).applyTransaction(transaction); + + // We don't need to explicitly use this here, the default transactor should + // be able to handle this (but it could be more future proof if we did support it later) + // await ctx.upsertAttachment(attachment, tx); + } + }); + + /** + * TODO, does the user want to have the attachment record peristed in this transaction or not? + * The implementation can be done according to the users's needs, devs should + * implement this saveFile override themselves, this is just an example. + * + * In this example, we write the attachment record first. + */ + tanStackDBTransaction.mutate(() => { + // save the attachment record + this.collection.insert(attachment); + // allow the user to associate values in this transaction + updateHook?.(attachment); + }); + + // Actually perform the transaction + await tanStackDBTransaction.commit(); + }); + + return attachment; + } +} diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx index d6ac28c01..a8335a02a 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx @@ -4,11 +4,19 @@ import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; import { TodosDeserializationSchema, TodosSchema } from '@/library/powersync/TodosSchema'; import { CircularProgress } from '@mui/material'; import { PowerSyncContext } from '@powersync/react'; -import { createBaseLogger, LogLevel, PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'; -import { createCollection } from '@tanstack/db'; +import { + createBaseLogger, + LogLevel, + PowerSyncDatabase, + WASQLiteOpenFactory, + WASQLiteVFS, + WatchedAttachmentItem +} from '@powersync/web'; +import { createCollection, isNull, liveQueryCollectionOptions, not } from '@tanstack/db'; import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection'; import React, { Suspense } from 'react'; import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; +import { LocalAttachmentStoage, RemoteAttachmentStorage, TanStackDBAttachmentQueue } from './Attachments'; const SupabaseContext = React.createContext(null); export const useSupabase = () => React.useContext(SupabaseContext); @@ -16,7 +24,7 @@ export const useSupabase = () => React.useContext(SupabaseContext); export const db = new PowerSyncDatabase({ schema: AppSchema, database: new WASQLiteOpenFactory({ - dbFilename: 'example.db', + dbFilename: 'example-v2.db', vfs: WASQLiteVFS.OPFSCoopSyncVFS }) }); @@ -47,6 +55,68 @@ export const todosCollection = createCollection( }) ); +// Keep the local only attachment records in sync with TanStackDB +export const attachmentsCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: AppSchema.props.attachments + }) +); + +export const attachmentQueue = new TanStackDBAttachmentQueue({ + db: db, // PowerSync database instance + attachmentsCollection: attachmentsCollection as any, //TODO better typing, + localStorage: LocalAttachmentStoage, + remoteStorage: RemoteAttachmentStorage, + + // Define which attachments exist in your data model + watchAttachments: async (onUpdate, abortSignal) => { + const livePhotoIds = createCollection( + liveQueryCollectionOptions({ + query: (q) => + q + .from({ document: listsCollection }) + .where(({ document }) => not(isNull(document.photo_id))) + .select(({ document }) => ({ + photo_id: document.photo_id + })) + }) + ); + + const initialState = await livePhotoIds.stateWhenReady(); + + type LivePhotoId = { photo_id: string | null }; + const mapper = (item: Partial) => + ({ id: item.photo_id!, fileExtension: 'jpg' }) satisfies WatchedAttachmentItem; + + // report the initial state of all active attachment IDs + onUpdate(Array.from(initialState.values()).map(mapper)); + + // Subscribe for future changes + livePhotoIds.subscribeChanges((changes) => { + // we need the wholistic state for at every change + const allPhotoIds = livePhotoIds.map(mapper); + onUpdate(allPhotoIds); + }); + + abortSignal.addEventListener( + 'abort', + () => { + // Stop the watched operations + livePhotoIds.cleanup(); + }, + { once: true } + ); + }, + + // Optional configuration + syncIntervalMs: 30000, // Sync every 30 seconds + downloadAttachments: true, // Auto-download referenced files + archivedCacheLimit: 100 // Keep 100 archived files before cleanup +}); + +attachmentQueue.startSync(); + export type EnhancedListRecord = ListRecord & { total_tasks: number; completed_tasks: number }; export const SystemProvider = ({ children }: { children: React.ReactNode }) => { diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx index 38752cba9..16834451b 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx @@ -25,11 +25,12 @@ export type ListItemWidgetProps = { id: string; title: string; description: string; + localUri?: string | null; selected?: boolean; }; export const ListItemWidget: React.FC = React.memo((props) => { - const { id, title, description, selected } = props; + const { id, title, description, localUri, selected } = props; const navigate = useNavigate(); @@ -79,14 +80,24 @@ export const ListItemWidget: React.FC = React.memo((props) - }> + } + > - + + {description} +
+ local_uri: {localUri ?? 'none'} + + } + />
diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/SearchBarWidget.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/SearchBarWidget.tsx index b775354d5..407ea2fe6 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/SearchBarWidget.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/SearchBarWidget.tsx @@ -25,7 +25,7 @@ export const SearchBarWidget: React.FC = () => { q .from({ todos: todosCollection }) .where(({ todos }) => like(todos.description, `%${searchInput}%`)) - .join({ lists: listsCollection }, ({ todos, lists }) => eq(todos.list_id, lists.id)) + .innerJoin({ lists: listsCollection }, ({ todos, lists }) => eq(todos.list_id, lists.id)) .select(({ todos, lists }) => ({ id: todos.id, list_id: todos.list_id, diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx index a20fa34ac..f069f21e0 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx @@ -1,6 +1,6 @@ import { List } from '@mui/material'; import { count, eq, sum, useLiveQuery } from '@tanstack/react-db'; -import { listsCollection, todosCollection } from '../providers/SystemProvider'; +import { attachmentsCollection, listsCollection, todosCollection } from '../providers/SystemProvider'; import { ListItemWidget } from './ListItemWidget'; export type TodoListsWidgetProps = { @@ -16,10 +16,12 @@ export function TodoListsWidget(props: TodoListsWidgetProps) { q .from({ lists: listsCollection }) .leftJoin({ todos: todosCollection }, ({ lists, todos }) => eq(lists.id, todos.list_id)) - .groupBy(({ lists }) => [lists.id, lists.name]) - .select(({ lists, todos }) => ({ + .leftJoin({ attachment: attachmentsCollection }, ({ lists, attachment }) => eq(lists.photo_id, attachment.id)) + .groupBy(({ lists, attachment }) => [lists.id, lists.name, attachment.local_uri]) + .select(({ lists, todos, attachment }) => ({ id: lists.id, name: lists.name, + attachment_local_uri: attachment?.local_uri, total_tasks: count(todos?.id), completed_tasks: sum(todos?.completed as number) })) @@ -41,6 +43,7 @@ export function TodoListsWidget(props: TodoListsWidgetProps) { id={r.id} title={r.name ?? ''} description={description(r.total_tasks, r.completed_tasks)} + localUri={r.attachment_local_uri} selected={r.id == props.selectedId} /> ))} diff --git a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/AppSchema.ts b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/AppSchema.ts index dfc6d1954..fb4fcdedc 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/AppSchema.ts +++ b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/AppSchema.ts @@ -1,8 +1,9 @@ -import { Schema } from '@powersync/web'; +import { AttachmentTable, Schema } from '@powersync/web'; import { LISTS_TABLE_DEFINITION } from './ListsSchema'; import { TODOS_TABLE_DEFINITION } from './TodosSchema'; export const AppSchema = new Schema({ todos: TODOS_TABLE_DEFINITION, - lists: LISTS_TABLE_DEFINITION + lists: LISTS_TABLE_DEFINITION, + attachments: new AttachmentTable() }); diff --git a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/ListsSchema.ts b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/ListsSchema.ts index db01387a9..dcfb6d0b4 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/ListsSchema.ts +++ b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/ListsSchema.ts @@ -8,7 +8,9 @@ import { stringToDate } from './zod-helpers'; export const LISTS_TABLE_DEFINITION = new Table({ created_at: column.text, name: column.text, - owner_id: column.text + owner_id: column.text, + // Relational Attachment ID for matching photos + photo_id: column.text }); /** @@ -19,7 +21,8 @@ export const ListsSchema = z.object({ id: z.string(), created_at: z.date(), name: z.string(), - owner_id: z.string() + owner_id: z.string(), + photo_id: z.string().nullable() }); /** @@ -29,7 +32,8 @@ export const ListsSchema = z.object({ */ export const ListsDeserializationSchema = z.object({ ...ListsSchema.shape, - created_at: stringToDate + created_at: stringToDate, + photo_id: z.string().nullable() }); export type ListRecord = z.output; diff --git a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/SupabaseConnector.ts b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/SupabaseConnector.ts index 07472b7e0..d7349249a 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/SupabaseConnector.ts +++ b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/SupabaseConnector.ts @@ -124,7 +124,7 @@ export class SupabaseConnector extends BaseObserver i result = await table.upsert(record); break; case UpdateType.PATCH: - result = await table.update(op.opData).eq('id', op.id); + result = await table.update(op.opData ?? {}).eq('id', op.id); break; case UpdateType.DELETE: result = await table.delete().eq('id', op.id); From cdc1233539a4fda746b2204ec9c7ebf4866f5844 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 18 Jun 2026 10:59:30 +0200 Subject: [PATCH 2/5] Deletes for attachments. --- .../src/components/providers/Attachments.ts | 100 ++++++++++-------- .../src/components/widgets/ListItemWidget.tsx | 24 ++++- .../components/widgets/TodoListsWidget.tsx | 4 +- 3 files changed, 81 insertions(+), 47 deletions(-) diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts index aed6dd38c..4a3558538 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts @@ -21,18 +21,38 @@ export const LocalAttachmentStoage = new IndexDBFileSystemStorageAdapter('my-app export const RemoteAttachmentStorage = { async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord) { // no-op for poc + console.warn('uploadFile', fileData, attachment); }, async downloadFile(attachment: AttachmentRecord): Promise { // no-op for poc + console.warn('downloadFile', attachment); return new ArrayBuffer(); }, async deleteFile(attachment: AttachmentRecord) { + console.warn('deleteFile', attachment); // no-op for poc } }; +interface SaveFileTanStackOptions { + data: AttachmentData; + fileExtension: string; + mediaType?: string; + metaData?: string; + id?: string; + /** + * Note that this is called inside a synchronous TanStackDB transaction, + * any mutations made to other collections will be in the same transaction. + */ + updateHook?: (attachment: AttachmentQueueRow) => Promise; +} + +interface DeleteFileTanStackOptions { + id: string; + updateHook?: (attachment: AttachmentQueueRow) => Promise; +} /** * This extends the default AttachmentQueue constructor params * FIXME(powersync) we should export this type from the common SDK. @@ -76,20 +96,6 @@ export class TanStackDBAttachmentQueue extends AttachmentQueue { this.collection = params.attachmentsCollection; } - /** - * HACK: The AttachmentQueue should make this protected instead, - * in order for extensions to use it. - */ - get _attachmentService(): AttachmentService { - // This is not protected, it's private and should be protected - return this['attachmentService'] as AttachmentService; - } - - /** - * Saves a new attachment given the input data. - * Provides an updateHook which is called inside a TanStackDB transaction. - * Relational associataions with the provded attachment ID should be made in this hook. - */ async saveFileTanStack({ data, fileExtension, @@ -97,16 +103,7 @@ export class TanStackDBAttachmentQueue extends AttachmentQueue { metaData, id, updateHook - }: { - data: AttachmentData; - fileExtension: string; - mediaType?: string; - metaData?: string; - id?: string; - // Note that this is called inside a synchronous TanStackDB transaction - // any mutations made to other collections, will be in the same transaction. - updateHook?: (attachment: AttachmentQueueRow) => Promise; - }): Promise { + }: SaveFileTanStackOptions): Promise { const resolvedId = id ?? (await this.generateAttachmentId()); const filename = `${resolvedId}.${fileExtension}`; const localUri = this.localStorage.getLocalUri(filename); @@ -125,44 +122,59 @@ export class TanStackDBAttachmentQueue extends AttachmentQueue { }; /** - * The use the attachmentService lock to prevent potential attachment queue race conditions. - * This specicifally prevents assuming a newly watched attachment record is one to download. - * */ - await this._attachmentService.withContext(async (ctx) => { - // Create a TanStackDB transaction context, the mutation will happen later + * We use the attachmentService lock to prevent attachment queue race conditions — specifically, + * it stops the watcher from treating a newly inserted attachment record as one that needs + * to be downloaded. + * */ + await this.withAttachmentContext(async (ctx) => { const tanStackDBTransaction = createTransaction({ autoCommit: false, mutationFn: async ({ transaction }) => { - // Now we should apply the actual operations. - // We can save the attachment using dedicated APIs await new PowerSyncTransactor({ database: ctx.db }).applyTransaction(transaction); - - // We don't need to explicitly use this here, the default transactor should - // be able to handle this (but it could be more future proof if we did support it later) - // await ctx.upsertAttachment(attachment, tx); } }); - /** - * TODO, does the user want to have the attachment record peristed in this transaction or not? - * The implementation can be done according to the users's needs, devs should - * implement this saveFile override themselves, this is just an example. - * - * In this example, we write the attachment record first. - */ tanStackDBTransaction.mutate(() => { - // save the attachment record this.collection.insert(attachment); // allow the user to associate values in this transaction updateHook?.(attachment); }); - // Actually perform the transaction await tanStackDBTransaction.commit(); }); return attachment; } + + async deleteFileTanStack({ id, updateHook }: DeleteFileTanStackOptions): Promise { + await this.withAttachmentContext(async (ctx) => { + const tanStackDBTransaction = createTransaction({ + autoCommit: false, + mutationFn: async ({ transaction }) => { + await new PowerSyncTransactor({ + database: ctx.db + }).applyTransaction(transaction); + } + }); + + tanStackDBTransaction.mutate(() => { + const attachment = this.collection.get(id); + if (!attachment) { + throw new Error(`Attachment with id ${id} not found`); + } + + this.collection.update(id, (draft) => { + draft.state = AttachmentState.QUEUED_DELETE; + draft.has_synced = 0; + }); + + // allow the user to associate values in this transaction + updateHook?.(attachment); + }); + + await tanStackDBTransaction.commit(); + }); + } } diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx index 16834451b..8be4c5e8a 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx @@ -19,18 +19,20 @@ import { usePowerSync } from '@powersync/react'; import { createTransaction } from '@tanstack/db'; import { PowerSyncTransactor } from '@tanstack/powersync-db-collection'; import { useNavigate } from 'react-router-dom'; -import { listsCollection, todosCollection } from '../providers/SystemProvider'; +import { attachmentQueue, listsCollection, todosCollection } from '../providers/SystemProvider'; +import { attachmentFromSql } from '@powersync/web'; export type ListItemWidgetProps = { id: string; title: string; description: string; localUri?: string | null; + photo_id?: string | null; selected?: boolean; }; export const ListItemWidget: React.FC = React.memo((props) => { - const { id, title, description, localUri, selected } = props; + const { id, title, description, localUri, selected, photo_id } = props; const navigate = useNavigate(); @@ -67,12 +69,30 @@ export const ListItemWidget: React.FC = React.memo((props) navigate(TODO_LISTS_ROUTE + '/' + id); }, [id]); + const deleteAttachment = React.useCallback(async () => { + console.warn("DELETE REquest for attachment", photo_id); + await attachmentQueue.deleteFileTanStack({ + + id: photo_id!, + updateHook: async (attachmentRecord) => { + // This should happen in the same transaction as creating the attachment + listsCollection.update(id, draft => { + draft.photo_id = null; + }); + + } + }) + }, [photo_id, id]); return ( + diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx index f069f21e0..da7774f4a 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx @@ -17,11 +17,12 @@ export function TodoListsWidget(props: TodoListsWidgetProps) { .from({ lists: listsCollection }) .leftJoin({ todos: todosCollection }, ({ lists, todos }) => eq(lists.id, todos.list_id)) .leftJoin({ attachment: attachmentsCollection }, ({ lists, attachment }) => eq(lists.photo_id, attachment.id)) - .groupBy(({ lists, attachment }) => [lists.id, lists.name, attachment.local_uri]) + .groupBy(({ lists, attachment }) => [lists.id, lists.name, attachment.local_uri, lists.photo_id]) .select(({ lists, todos, attachment }) => ({ id: lists.id, name: lists.name, attachment_local_uri: attachment?.local_uri, + photo_id: lists.photo_id, total_tasks: count(todos?.id), completed_tasks: sum(todos?.completed as number) })) @@ -43,6 +44,7 @@ export function TodoListsWidget(props: TodoListsWidgetProps) { id={r.id} title={r.name ?? ''} description={description(r.total_tasks, r.completed_tasks)} + photo_id={r.photo_id} localUri={r.attachment_local_uri} selected={r.id == props.selectedId} /> From ae62bf7146fb36e405d5a8ee74ae07b053ce9fe9 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 18 Jun 2026 13:04:32 +0200 Subject: [PATCH 3/5] Added Supabase remote storage. --- .../.env.local.template | 1 + .../e2e/mocks/SupabaseConnector.ts | 4 +- .../src/components/providers/Attachments.ts | 20 ------- .../components/providers/SystemProvider.tsx | 36 +++++++++++-- .../src/components/widgets/ListItemWidget.tsx | 17 +++--- .../library/powersync/SupabaseConnector.ts | 4 +- .../src/library/powersync/vite-env.d.ts | 1 + .../storage/SupabaseRemoteStorageAdapter.ts | 53 +++++++++++++++++++ 8 files changed, 103 insertions(+), 33 deletions(-) create mode 100644 demos/react-supabase-todolist-tanstackdb/src/library/storage/SupabaseRemoteStorageAdapter.ts diff --git a/demos/react-supabase-todolist-tanstackdb/.env.local.template b/demos/react-supabase-todolist-tanstackdb/.env.local.template index dc4088ca7..5e0a7fcf5 100644 --- a/demos/react-supabase-todolist-tanstackdb/.env.local.template +++ b/demos/react-supabase-todolist-tanstackdb/.env.local.template @@ -2,4 +2,5 @@ # Edit .env.local and enter your Supabase and PowerSync project details. VITE_SUPABASE_URL=https://foo.supabase.co VITE_SUPABASE_ANON_KEY=foo +VITE_SUPABASE_BUCKET= # Optional. Only required when syncing attachments to Supabase Storage. VITE_POWERSYNC_URL=https://foo.powersync.journeyapps.com diff --git a/demos/react-supabase-todolist-tanstackdb/e2e/mocks/SupabaseConnector.ts b/demos/react-supabase-todolist-tanstackdb/e2e/mocks/SupabaseConnector.ts index 2d6d2259b..6d1679782 100644 --- a/demos/react-supabase-todolist-tanstackdb/e2e/mocks/SupabaseConnector.ts +++ b/demos/react-supabase-todolist-tanstackdb/e2e/mocks/SupabaseConnector.ts @@ -11,6 +11,7 @@ export const MOCK_USER_ID = 'test-user-123'; export type SupabaseConfig = { supabaseUrl: string; supabaseAnonKey: string; + supabaseBucket: string; powersyncUrl: string; }; @@ -51,7 +52,8 @@ export class SupabaseConnector extends BaseObserver i this.config = { supabaseUrl: 'https://mock.supabase.test', powersyncUrl: 'https://mock.powersync.test', - supabaseAnonKey: 'mock-anon-key' + supabaseAnonKey: 'mock-anon-key', + supabaseBucket: '' }; this.client = new MockSupabaseClient(); diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts index 4a3558538..ee998c21a 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts @@ -4,8 +4,6 @@ import { AttachmentData, AttachmentErrorHandler, AttachmentQueue, - AttachmentRecord, - AttachmentService, AttachmentState, ILogger, IndexDBFileSystemStorageAdapter, @@ -18,24 +16,6 @@ import { PowerSyncTransactor } from '@tanstack/powersync-db-collection'; export const LocalAttachmentStoage = new IndexDBFileSystemStorageAdapter('my-app-files'); -export const RemoteAttachmentStorage = { - async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord) { - // no-op for poc - console.warn('uploadFile', fileData, attachment); - }, - - async downloadFile(attachment: AttachmentRecord): Promise { - // no-op for poc - console.warn('downloadFile', attachment); - return new ArrayBuffer(); - }, - - async deleteFile(attachment: AttachmentRecord) { - console.warn('deleteFile', attachment); - // no-op for poc - } -}; - interface SaveFileTanStackOptions { data: AttachmentData; fileExtension: string; diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx index a8335a02a..fec01b3ef 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx @@ -2,9 +2,11 @@ import { AppSchema } from '@/library/powersync/AppSchema'; import { ListRecord, ListsDeserializationSchema, ListsSchema } from '@/library/powersync/ListsSchema'; import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; import { TodosDeserializationSchema, TodosSchema } from '@/library/powersync/TodosSchema'; +import { SupabaseRemoteStorageAdapter } from '@/library/storage/SupabaseRemoteStorageAdapter'; import { CircularProgress } from '@mui/material'; import { PowerSyncContext } from '@powersync/react'; import { + AttachmentRecord, createBaseLogger, LogLevel, PowerSyncDatabase, @@ -16,7 +18,7 @@ import { createCollection, isNull, liveQueryCollectionOptions, not } from '@tans import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection'; import React, { Suspense } from 'react'; import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; -import { LocalAttachmentStoage, RemoteAttachmentStorage, TanStackDBAttachmentQueue } from './Attachments'; +import { LocalAttachmentStoage, TanStackDBAttachmentQueue } from './Attachments'; const SupabaseContext = React.createContext(null); export const useSupabase = () => React.useContext(SupabaseContext); @@ -29,6 +31,15 @@ export const db = new PowerSyncDatabase({ }) }); +// The connector owns the authenticated Supabase client, which the remote +// storage adapter reuses to upload/download/delete attachments. +export const connector = new SupabaseConnector(); + +export const RemoteAttachmentStorage = new SupabaseRemoteStorageAdapter({ + client: connector.client, + bucket: connector.config.supabaseBucket +}); + export const listsCollection = createCollection( powerSyncCollectionOptions({ database: db, @@ -93,7 +104,7 @@ export const attachmentQueue = new TanStackDBAttachmentQueue({ onUpdate(Array.from(initialState.values()).map(mapper)); // Subscribe for future changes - livePhotoIds.subscribeChanges((changes) => { + livePhotoIds.subscribeChanges(() => { // we need the wholistic state for at every change const allPhotoIds = livePhotoIds.map(mapper); onUpdate(allPhotoIds); @@ -109,18 +120,35 @@ export const attachmentQueue = new TanStackDBAttachmentQueue({ ); }, + errorHandler: { + onDownloadError: async (attachment: AttachmentRecord, error: Error) => { + // Object not found - the file no longer exists remotely, so don't retry. + if (error.toString().includes('Object not found')) { + return false; + } + return true; // Retry other download errors (e.g. transient network failures). + }, + onUploadError: async () => true, // Retry uploads by default. + onDeleteError: async () => true // Retry deletes by default. + }, + // Optional configuration syncIntervalMs: 30000, // Sync every 30 seconds downloadAttachments: true, // Auto-download referenced files archivedCacheLimit: 100 // Keep 100 archived files before cleanup }); -attachmentQueue.startSync(); +// Only sync attachments to/from Supabase Storage when a bucket is configured. +// Without a bucket, files are still saved locally but never uploaded. +if (connector.config.supabaseBucket) { + attachmentQueue.startSync(); +} else { + console.warn('VITE_SUPABASE_BUCKET is not set — attachments will be stored locally but not synced.'); +} export type EnhancedListRecord = ListRecord & { total_tasks: number; completed_tasks: number }; export const SystemProvider = ({ children }: { children: React.ReactNode }) => { - const [connector] = React.useState(() => new SupabaseConnector()); const [powerSync] = React.useState(db); React.useEffect(() => { diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx index 8be4c5e8a..90a6fdaa4 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx @@ -7,6 +7,7 @@ import { ListItemButton, ListItemText, Paper, + Tooltip, styled } from '@mui/material'; import React from 'react'; @@ -14,6 +15,7 @@ import React from 'react'; import { TODO_LISTS_ROUTE } from '@/app/router'; import RightIcon from '@mui/icons-material/ArrowRightAlt'; import DeleteIcon from '@mui/icons-material/DeleteOutline'; +import HideImageOutlinedIcon from '@mui/icons-material/HideImageOutlined'; import ListIcon from '@mui/icons-material/ListAltOutlined'; import { usePowerSync } from '@powersync/react'; import { createTransaction } from '@tanstack/db'; @@ -70,9 +72,7 @@ export const ListItemWidget: React.FC = React.memo((props) }, [id]); const deleteAttachment = React.useCallback(async () => { - console.warn("DELETE REquest for attachment", photo_id); await attachmentQueue.deleteFileTanStack({ - id: photo_id!, updateHook: async (attachmentRecord) => { // This should happen in the same transaction as creating the attachment @@ -88,11 +88,14 @@ export const ListItemWidget: React.FC = React.memo((props) - + + {photo_id && ( + + + + + + )} diff --git a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/SupabaseConnector.ts b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/SupabaseConnector.ts index d7349249a..2a560e70b 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/SupabaseConnector.ts +++ b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/SupabaseConnector.ts @@ -12,6 +12,7 @@ import { Session, SupabaseClient, createClient } from '@supabase/supabase-js'; export type SupabaseConfig = { supabaseUrl: string; supabaseAnonKey: string; + supabaseBucket: string; powersyncUrl: string; }; @@ -45,7 +46,8 @@ export class SupabaseConnector extends BaseObserver i this.config = { supabaseUrl: import.meta.env.VITE_SUPABASE_URL, powersyncUrl: import.meta.env.VITE_POWERSYNC_URL, - supabaseAnonKey: import.meta.env.VITE_SUPABASE_ANON_KEY + supabaseAnonKey: import.meta.env.VITE_SUPABASE_ANON_KEY, + supabaseBucket: import.meta.env.VITE_SUPABASE_BUCKET }; this.client = createClient(this.config.supabaseUrl, this.config.supabaseAnonKey, { diff --git a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/vite-env.d.ts b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/vite-env.d.ts index e3e71b5ba..a3ce33cfd 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/library/powersync/vite-env.d.ts +++ b/demos/react-supabase-todolist-tanstackdb/src/library/powersync/vite-env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_SUPABASE_URL: string; readonly VITE_SUPABASE_ANON_KEY: string; + readonly VITE_SUPABASE_BUCKET: string; readonly VITE_POWERSYNC_URL: string; } diff --git a/demos/react-supabase-todolist-tanstackdb/src/library/storage/SupabaseRemoteStorageAdapter.ts b/demos/react-supabase-todolist-tanstackdb/src/library/storage/SupabaseRemoteStorageAdapter.ts new file mode 100644 index 000000000..193aecf21 --- /dev/null +++ b/demos/react-supabase-todolist-tanstackdb/src/library/storage/SupabaseRemoteStorageAdapter.ts @@ -0,0 +1,53 @@ +import { type AttachmentRecord, RemoteStorageAdapter } from '@powersync/web'; +import { SupabaseClient } from '@supabase/supabase-js'; + +export interface SupabaseRemoteStorageAdapterOptions { + client: SupabaseClient; + bucket: string; +} + +/** + * Implements RemoteStorageAdapter for Supabase Storage. + * Handles upload, download, and deletion of files from a Supabase Storage bucket. + */ +export class SupabaseRemoteStorageAdapter implements RemoteStorageAdapter { + constructor(private options: SupabaseRemoteStorageAdapterOptions) {} + + async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise { + 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 { + 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 { + 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; + } + } +} From 1416e3dc80a24ed979e26766021db8aa3ee648df Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 22 Jun 2026 15:27:39 +0200 Subject: [PATCH 4/5] Pulled Attachment setup code out into a dedicated provider that only shows if the bucket env config is specified. --- .../src/app/App.tsx | 5 +- .../src/app/views/todo-lists/page.tsx | 47 ++++--- .../src/components/providers/Attachments.ts | 4 +- .../providers/AttachmentsProvider.tsx | 121 ++++++++++++++++++ .../components/providers/SystemProvider.tsx | 105 +-------------- .../src/components/widgets/ListItemWidget.tsx | 82 +++++++----- .../components/widgets/TodoListsWidget.tsx | 3 +- 7 files changed, 216 insertions(+), 151 deletions(-) create mode 100644 demos/react-supabase-todolist-tanstackdb/src/components/providers/AttachmentsProvider.tsx diff --git a/demos/react-supabase-todolist-tanstackdb/src/app/App.tsx b/demos/react-supabase-todolist-tanstackdb/src/app/App.tsx index a04352f70..8ac37486d 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/app/App.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/app/App.tsx @@ -1,4 +1,5 @@ import { RouterProvider } from 'react-router-dom'; +import { AttachmentsProvider } from '@/components/providers/AttachmentsProvider'; import { SystemProvider } from '@/components/providers/SystemProvider'; import { ThemeProviderContainer } from '@/components/providers/ThemeProviderContainer'; import { router } from '@/app/router'; @@ -7,7 +8,9 @@ export function App() { return ( - + + + ); diff --git a/demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx b/demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx index 45810147e..10bb2936b 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx @@ -1,5 +1,6 @@ import { NavigationPage } from '@/components/navigation/NavigationPage'; -import { attachmentQueue, listsCollection, useSupabase } from '@/components/providers/SystemProvider'; +import { useAttachments } from '@/components/providers/AttachmentsProvider'; +import { listsCollection, useSupabase } from '@/components/providers/SystemProvider'; import { GuardBySync } from '@/components/widgets/GuardBySync'; import { SearchBarWidget } from '@/components/widgets/SearchBarWidget'; import { TodoListsWidget } from '@/components/widgets/TodoListsWidget'; @@ -20,6 +21,7 @@ import React from 'react'; export default function TodoListsPage() { const supabase = useSupabase(); + const attachments = useAttachments(); const [showPrompt, setShowPrompt] = React.useState(false); const nameInputRef = React.createRef(); @@ -31,21 +33,34 @@ export default function TodoListsPage() { throw new Error(`Could not create new lists, no userID found`); } - await attachmentQueue.saveFileTanStack({ - // This is just random file data for this poc, this could be an image from a camera etc - data: btoa(crypto.randomUUID()), - fileExtension: 'jpg', - updateHook: async (attachmentRecord) => { - // This should happen in the same transaction as creating the attachment - listsCollection.insert({ - id: crypto.randomUUID(), - name, - created_at: new Date(), - owner_id: userID, - photo_id: attachmentRecord.id // make the association for related data - }); - } - }); + if (attachments) { + // Attachments are enabled: create the list together with a photo attachment + // in a single transaction, associating them via `photo_id`. + await attachments.queue.saveFileTanStack({ + // This is just random file data for this poc, this could be an image from a camera etc + data: btoa(crypto.randomUUID()), + fileExtension: 'jpg', + updateHook: async (attachmentRecord) => { + // This should happen in the same transaction as creating the attachment + listsCollection.insert({ + id: crypto.randomUUID(), + name, + created_at: new Date(), + owner_id: userID, + photo_id: attachmentRecord.id // make the association for related data + }); + } + }); + } else { + // No attachments configured: create the list on its own. + await listsCollection.insert({ + id: crypto.randomUUID(), + name, + created_at: new Date(), + owner_id: userID, + photo_id: null + }).isPersisted.promise; + } }; return ( diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts index ee998c21a..d454c5310 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts @@ -44,7 +44,7 @@ type TanStackDBAttachmentQueueParams = { * In order to have the same relational data be set in a single transaction. * This also allows for joining both TanStackDB collections. */ - attachmentsCollection: Collection; + attachmentsCollection: Collection; remoteStorage: RemoteStorageAdapter; localStorage: LocalStorageAdapter; watchAttachments: (onUpdate: (attachment: WatchedAttachmentItem[]) => Promise, signal: AbortSignal) => void; @@ -68,7 +68,7 @@ type AttachmentQueueRow = (typeof AppSchema)['types']['attachments']; */ export class TanStackDBAttachmentQueue extends AttachmentQueue { readonly powersync: AbstractPowerSyncDatabase; - readonly collection: Collection; + readonly collection: Collection; constructor(params: TanStackDBAttachmentQueueParams) { super(params); diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/AttachmentsProvider.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/providers/AttachmentsProvider.tsx new file mode 100644 index 000000000..23e480273 --- /dev/null +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/AttachmentsProvider.tsx @@ -0,0 +1,121 @@ +import { SupabaseRemoteStorageAdapter } from '@/library/storage/SupabaseRemoteStorageAdapter'; +import { AttachmentRecord, WatchedAttachmentItem } from '@powersync/web'; +import { createCollection, isNull, liveQueryCollectionOptions, not } from '@tanstack/db'; +import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection'; +import React from 'react'; +import { AppSchema } from '@/library/powersync/AppSchema'; +import { LocalAttachmentStoage, TanStackDBAttachmentQueue } from './Attachments'; +import { connector, db, listsCollection } from './SystemProvider'; + +export const attachmentsEnabled = !!connector.config.supabaseBucket; + +// The connector owns the authenticated Supabase client, which the remote +// storage adapter reuses to upload/download/delete attachments. +export const RemoteAttachmentStorage = new SupabaseRemoteStorageAdapter({ + client: connector.client, + bucket: connector.config.supabaseBucket +}); + +// Keep the local only attachment records in sync with TanStackDB. +// This is a plain mirror of the local attachments table, so it is harmless when +// attachments are disabled — the table simply stays empty. +export const attachmentsCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: AppSchema.props.attachments + }) +); + +export const attachmentQueue = new TanStackDBAttachmentQueue({ + db: db, + attachmentsCollection: attachmentsCollection, + localStorage: LocalAttachmentStoage, + remoteStorage: RemoteAttachmentStorage, + + // Define which attachments exist in your data model + watchAttachments: async (onUpdate, abortSignal) => { + const livePhotoIds = createCollection( + liveQueryCollectionOptions({ + query: (q) => + q + .from({ document: listsCollection }) + .where(({ document }) => not(isNull(document.photo_id))) + .select(({ document }) => ({ + photo_id: document.photo_id + })) + }) + ); + + const initialState = await livePhotoIds.stateWhenReady(); + + type LivePhotoId = { photo_id: string | null }; + const mapper = (item: Partial) => + ({ id: item.photo_id!, fileExtension: 'jpg' }) satisfies WatchedAttachmentItem; + + // report the initial state of all active attachment IDs + onUpdate(Array.from(initialState.values()).map(mapper)); + + // Subscribe for future changes + livePhotoIds.subscribeChanges(() => { + // we need the wholistic state for at every change + const allPhotoIds = livePhotoIds.map(mapper); + onUpdate(allPhotoIds); + }); + + abortSignal.addEventListener( + 'abort', + () => { + // Stop the watched operations + livePhotoIds.cleanup(); + }, + { once: true } + ); + }, + + errorHandler: { + onDownloadError: async (_attachment: AttachmentRecord, error: Error) => { + // Object not found - the file no longer exists remotely, so don't retry. + if (error.toString().includes('Object not found')) { + return false; + } + return true; // Retry other download errors (e.g. transient network failures). + }, + onUploadError: async () => true, // Retry uploads by default. + onDeleteError: async () => true // Retry deletes by default. + } +}); + +export type AttachmentsContextValue = { + queue: TanStackDBAttachmentQueue; +}; + +const AttachmentsContext = React.createContext(null); + +/** + * Exposes the attachment queue when a Supabase bucket is configured. + * Returns `null` when attachments are disabled, so consumers can degrade + * gracefully (e.g. create lists without a photo). + */ +export const useAttachments = () => React.useContext(AttachmentsContext); + +export const AttachmentsProvider = ({ children }: { children: React.ReactNode }) => { + React.useEffect(() => { + if (!attachmentsEnabled) { + console.warn('VITE_SUPABASE_BUCKET is not set.'); + return; + } + + attachmentQueue.startSync(); + return () => { + attachmentQueue.stopSync(); + }; + }, []); + + return ( + + {children} + + ); +}; + +export default AttachmentsProvider; diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx index fec01b3ef..da84f88b7 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx @@ -2,23 +2,13 @@ import { AppSchema } from '@/library/powersync/AppSchema'; import { ListRecord, ListsDeserializationSchema, ListsSchema } from '@/library/powersync/ListsSchema'; import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; import { TodosDeserializationSchema, TodosSchema } from '@/library/powersync/TodosSchema'; -import { SupabaseRemoteStorageAdapter } from '@/library/storage/SupabaseRemoteStorageAdapter'; import { CircularProgress } from '@mui/material'; import { PowerSyncContext } from '@powersync/react'; -import { - AttachmentRecord, - createBaseLogger, - LogLevel, - PowerSyncDatabase, - WASQLiteOpenFactory, - WASQLiteVFS, - WatchedAttachmentItem -} from '@powersync/web'; -import { createCollection, isNull, liveQueryCollectionOptions, not } from '@tanstack/db'; +import { createBaseLogger, LogLevel, PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'; +import { createCollection } from '@tanstack/db'; import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection'; import React, { Suspense } from 'react'; import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; -import { LocalAttachmentStoage, TanStackDBAttachmentQueue } from './Attachments'; const SupabaseContext = React.createContext(null); export const useSupabase = () => React.useContext(SupabaseContext); @@ -26,20 +16,13 @@ export const useSupabase = () => React.useContext(SupabaseContext); export const db = new PowerSyncDatabase({ schema: AppSchema, database: new WASQLiteOpenFactory({ - dbFilename: 'example-v2.db', + dbFilename: 'example.db', vfs: WASQLiteVFS.OPFSCoopSyncVFS }) }); -// The connector owns the authenticated Supabase client, which the remote -// storage adapter reuses to upload/download/delete attachments. export const connector = new SupabaseConnector(); -export const RemoteAttachmentStorage = new SupabaseRemoteStorageAdapter({ - client: connector.client, - bucket: connector.config.supabaseBucket -}); - export const listsCollection = createCollection( powerSyncCollectionOptions({ database: db, @@ -66,86 +49,6 @@ export const todosCollection = createCollection( }) ); -// Keep the local only attachment records in sync with TanStackDB -export const attachmentsCollection = createCollection( - powerSyncCollectionOptions({ - database: db, - table: AppSchema.props.attachments - }) -); - -export const attachmentQueue = new TanStackDBAttachmentQueue({ - db: db, // PowerSync database instance - attachmentsCollection: attachmentsCollection as any, //TODO better typing, - localStorage: LocalAttachmentStoage, - remoteStorage: RemoteAttachmentStorage, - - // Define which attachments exist in your data model - watchAttachments: async (onUpdate, abortSignal) => { - const livePhotoIds = createCollection( - liveQueryCollectionOptions({ - query: (q) => - q - .from({ document: listsCollection }) - .where(({ document }) => not(isNull(document.photo_id))) - .select(({ document }) => ({ - photo_id: document.photo_id - })) - }) - ); - - const initialState = await livePhotoIds.stateWhenReady(); - - type LivePhotoId = { photo_id: string | null }; - const mapper = (item: Partial) => - ({ id: item.photo_id!, fileExtension: 'jpg' }) satisfies WatchedAttachmentItem; - - // report the initial state of all active attachment IDs - onUpdate(Array.from(initialState.values()).map(mapper)); - - // Subscribe for future changes - livePhotoIds.subscribeChanges(() => { - // we need the wholistic state for at every change - const allPhotoIds = livePhotoIds.map(mapper); - onUpdate(allPhotoIds); - }); - - abortSignal.addEventListener( - 'abort', - () => { - // Stop the watched operations - livePhotoIds.cleanup(); - }, - { once: true } - ); - }, - - errorHandler: { - onDownloadError: async (attachment: AttachmentRecord, error: Error) => { - // Object not found - the file no longer exists remotely, so don't retry. - if (error.toString().includes('Object not found')) { - return false; - } - return true; // Retry other download errors (e.g. transient network failures). - }, - onUploadError: async () => true, // Retry uploads by default. - onDeleteError: async () => true // Retry deletes by default. - }, - - // Optional configuration - syncIntervalMs: 30000, // Sync every 30 seconds - downloadAttachments: true, // Auto-download referenced files - archivedCacheLimit: 100 // Keep 100 archived files before cleanup -}); - -// Only sync attachments to/from Supabase Storage when a bucket is configured. -// Without a bucket, files are still saved locally but never uploaded. -if (connector.config.supabaseBucket) { - attachmentQueue.startSync(); -} else { - console.warn('VITE_SUPABASE_BUCKET is not set — attachments will be stored locally but not synced.'); -} - export type EnhancedListRecord = ListRecord & { total_tasks: number; completed_tasks: number }; export const SystemProvider = ({ children }: { children: React.ReactNode }) => { @@ -160,7 +63,7 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { powerSync.init(); const l = connector.registerListener({ - initialized: () => {}, + initialized: () => { }, sessionStarted: () => { powerSync.connect(connector); } diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx index 90a6fdaa4..f31647a03 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx @@ -21,8 +21,8 @@ import { usePowerSync } from '@powersync/react'; import { createTransaction } from '@tanstack/db'; import { PowerSyncTransactor } from '@tanstack/powersync-db-collection'; import { useNavigate } from 'react-router-dom'; -import { attachmentQueue, listsCollection, todosCollection } from '../providers/SystemProvider'; -import { attachmentFromSql } from '@powersync/web'; +import { useAttachments } from '../providers/AttachmentsProvider'; +import { listsCollection, todosCollection } from '../providers/SystemProvider'; export type ListItemWidgetProps = { id: string; @@ -37,59 +37,77 @@ export const ListItemWidget: React.FC = React.memo((props) const { id, title, description, localUri, selected, photo_id } = props; const navigate = useNavigate(); + const attachments = useAttachments(); const powerSync = usePowerSync(); - const deleteList = React.useCallback(async () => { - // Create a transaction that won't auto-commit - const batchTx = createTransaction({ - autoCommit: false, - mutationFn: async ({ transaction }) => { - // Use PowerSyncTransactor to apply the transaction to PowerSync - await new PowerSyncTransactor({ database: powerSync }).applyTransaction(transaction); + const deleteListAndTodos = React.useCallback(() => { + listsCollection.delete(id); + todosCollection.forEach((todo) => { + if (todo.list_id === id) { + todosCollection.delete(todo.id); } }); + }, [id]); - // Perform multiple operations in the transaction - batchTx.mutate(() => { - listsCollection.delete(id); - todosCollection.forEach((todo) => { - if (todo.list_id === id) { - todosCollection.delete(todo.id); + const deleteList = React.useCallback(async () => { + if (attachments && photo_id) { + await attachments.queue.deleteFileTanStack({ + id: photo_id, + updateHook: async () => { + // This happens in the same transaction as queueing the attachment delete + deleteListAndTodos(); + } + }); + } else { + + // Create a transaction that won't auto-commit + const batchTx = createTransaction({ + autoCommit: false, + mutationFn: async ({ transaction }) => { + // Use PowerSyncTransactor to apply the transaction to PowerSync + await new PowerSyncTransactor({ database: powerSync }).applyTransaction(transaction); } }); - }); - // Commit the transaction - await batchTx.commit(); + // Perform multiple operations in the transaction + batchTx.mutate(() => { + deleteListAndTodos(); + }); - // Wait for the changes to be persisted - await batchTx.isPersisted.promise; - }, [id]); + // Commit the transaction + await batchTx.commit(); + + // Wait for the changes to be persisted + await batchTx.isPersisted.promise; + } + }, [attachments, photo_id, powerSync, deleteListAndTodos]); const openList = React.useCallback(() => { navigate(TODO_LISTS_ROUTE + '/' + id); }, [id]); const deleteAttachment = React.useCallback(async () => { - await attachmentQueue.deleteFileTanStack({ + if (!attachments) { + return; + } + await attachments.queue.deleteFileTanStack({ id: photo_id!, - updateHook: async (attachmentRecord) => { + updateHook: async () => { // This should happen in the same transaction as creating the attachment - listsCollection.update(id, draft => { + listsCollection.update(id, (draft) => { draft.photo_id = null; }); - } - }) - }, [photo_id, id]); + }); + }, [attachments, photo_id, id]); return ( - {photo_id && ( + {attachments && photo_id && ( @@ -116,8 +134,12 @@ export const ListItemWidget: React.FC = React.memo((props) secondary={ <> {description} -
- local_uri: {localUri ?? 'none'} + {attachments && ( + <> +
+ local_uri: {localUri ?? 'none'} + + )} } /> diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx index da7774f4a..040d9a364 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx @@ -1,6 +1,7 @@ import { List } from '@mui/material'; import { count, eq, sum, useLiveQuery } from '@tanstack/react-db'; -import { attachmentsCollection, listsCollection, todosCollection } from '../providers/SystemProvider'; +import { attachmentsCollection } from '../providers/AttachmentsProvider'; +import { listsCollection, todosCollection } from '../providers/SystemProvider'; import { ListItemWidget } from './ListItemWidget'; export type TodoListsWidgetProps = { From 2fad266d3bcb8902f2fc1c657b79c8deb0584ecb Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Tue, 23 Jun 2026 10:02:50 +0200 Subject: [PATCH 5/5] Updated readme. --- .../README.md | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/demos/react-supabase-todolist-tanstackdb/README.md b/demos/react-supabase-todolist-tanstackdb/README.md index e7dcef17b..d07608c7d 100644 --- a/demos/react-supabase-todolist-tanstackdb/README.md +++ b/demos/react-supabase-todolist-tanstackdb/README.md @@ -7,9 +7,10 @@ Demo app demonstrating use of the [PowerSync SDK for Web](https://www.npmjs.com/ ## Run Demo Prerequisites: -* To run this demo, you need to have properly configured Supabase and PowerSync projects. Follow the instructions in our Supabase<>PowerSync integration guide: - * [Configure Supabase](https://docs.powersync.com/integration-guides/supabase-+-powersync#configure-supabase) - * [Configure PowerSync](https://docs.powersync.com/integration-guides/supabase-+-powersync#configure-powersync) + +- To run this demo, you need to have properly configured Supabase and PowerSync projects. Follow the instructions in our Supabase<>PowerSync integration guide: + - [Configure Supabase](https://docs.powersync.com/integration-guides/supabase-+-powersync#configure-supabase) + - [Configure PowerSync](https://docs.powersync.com/integration-guides/supabase-+-powersync#configure-powersync) Switch into the demo's directory: @@ -39,6 +40,22 @@ pnpm dev Open [http://localhost:5173](http://localhost:5173) with your browser to see the result. +## Attachments + +This demo optionally syncs a photo per list, demonstrating PowerSync attachments with the TanStack DB +integration. It is powered by `TanStackDBAttachmentQueue` from +[`@tanstack/powersync-db-collection`](https://github.com/powersync-ja/tanstack-db/tree/main/packages/powersync-db-collection), +which commits the attachment record and the related list row in a single transaction. See that +package's documentation for the usage guide. + +### Enabling attachments + +Attachments are **off by default** and the demo downgrades gracefully — lists are created without a photo and no +attachment UI is shown when disabled. To enable them: + +1. Create a [Supabase Storage](https://supabase.com/docs/guides/storage) bucket in your Supabase project. +2. Set `VITE_SUPABASE_BUCKET` in `.env.local` to the bucket name. + ## Progressive Web App (PWA) This demo is PWA compatible, and works fully offline. PWA is not available in development (watch) mode. The manifest and service worker is built using [vite-plugin-pwa](https://vite-pwa-org.netlify.app/).