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/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/). 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/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/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 ce483d240..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,4 +1,5 @@ import { NavigationPage } from '@/components/navigation/NavigationPage'; +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'; @@ -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,13 +33,34 @@ 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; + 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 new file mode 100644 index 000000000..d454c5310 --- /dev/null +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/Attachments.ts @@ -0,0 +1,160 @@ +import { AppSchema } from '@/library/powersync/AppSchema'; +import { + AbstractPowerSyncDatabase, + AttachmentData, + AttachmentErrorHandler, + AttachmentQueue, + 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'); + +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. + */ +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; + } + + async saveFileTanStack({ + data, + fileExtension, + mediaType, + metaData, + id, + updateHook + }: SaveFileTanStackOptions): 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 + }; + + /** + * 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 }) => { + await new PowerSyncTransactor({ + database: ctx.db + }).applyTransaction(transaction); + } + }); + + tanStackDBTransaction.mutate(() => { + this.collection.insert(attachment); + // allow the user to associate values in this transaction + updateHook?.(attachment); + }); + + 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/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 d6ac28c01..da84f88b7 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx @@ -21,6 +21,8 @@ export const db = new PowerSyncDatabase({ }) }); +export const connector = new SupabaseConnector(); + export const listsCollection = createCollection( powerSyncCollectionOptions({ database: db, @@ -50,7 +52,6 @@ export const todosCollection = createCollection( 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(() => { @@ -62,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 38752cba9..f31647a03 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,64 +15,105 @@ 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'; import { PowerSyncTransactor } from '@tanstack/powersync-db-collection'; import { useNavigate } from 'react-router-dom'; +import { useAttachments } from '../providers/AttachmentsProvider'; import { listsCollection, todosCollection } from '../providers/SystemProvider'; 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, selected } = 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 { - // Commit the transaction - await batchTx.commit(); + // 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); + } + }); - // Wait for the changes to be persisted - await batchTx.isPersisted.promise; - }, [id]); + // Perform multiple operations in the transaction + batchTx.mutate(() => { + deleteListAndTodos(); + }); + + // 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 () => { + if (!attachments) { + return; + } + await attachments.queue.deleteFileTanStack({ + id: photo_id!, + updateHook: async () => { + // This should happen in the same transaction as creating the attachment + listsCollection.update(id, (draft) => { + draft.photo_id = null; + }); + } + }); + }, [attachments, photo_id, id]); return ( + + {attachments && photo_id && ( + + + + + + )} @@ -79,14 +121,28 @@ export const ListItemWidget: React.FC = React.memo((props) - }> + } + > - + + {description} + {attachments && ( + <> +
+ 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..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,5 +1,6 @@ import { List } from '@mui/material'; import { count, eq, sum, useLiveQuery } from '@tanstack/react-db'; +import { attachmentsCollection } from '../providers/AttachmentsProvider'; import { listsCollection, todosCollection } from '../providers/SystemProvider'; import { ListItemWidget } from './ListItemWidget'; @@ -16,10 +17,13 @@ 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, 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) })) @@ -41,6 +45,8 @@ 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} /> ))} 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..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, { @@ -124,7 +126,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); 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; + } + } +}