From 58c15a27872d8ba55011d19274d20135156a9110 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Fri, 29 May 2026 14:33:38 +0000 Subject: [PATCH] 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);