Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 20 additions & 3 deletions demos/react-supabase-todolist-tanstackdb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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/).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const MOCK_USER_ID = 'test-user-123';
export type SupabaseConfig = {
supabaseUrl: string;
supabaseAnonKey: string;
supabaseBucket: string;
powersyncUrl: string;
};

Expand Down Expand Up @@ -51,7 +52,8 @@ export class SupabaseConnector extends BaseObserver<SupabaseConnectorListener> 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();
Expand Down
4 changes: 4 additions & 0 deletions demos/react-supabase-todolist-tanstackdb/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ allowBuilds:
'@journeyapps/wa-sqlite': true
'@swc/core': true
esbuild: true

trustPolicyExclude:
- rollup@2.80.0
- semver@6.3.1
5 changes: 4 additions & 1 deletion demos/react-supabase-todolist-tanstackdb/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -7,7 +8,9 @@ export function App() {
return (
<ThemeProviderContainer>
<SystemProvider>
<RouterProvider router={router} />
<AttachmentsProvider>
<RouterProvider router={router} />
</AttachmentsProvider>
</SystemProvider>
</ThemeProviderContainer>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<HTMLInputElement>();
Expand All @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;
}

interface DeleteFileTanStackOptions {
id: string;
updateHook?: (attachment: AttachmentQueueRow) => Promise<void>;
}
/**
* 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<AttachmentQueueRow, string>;
remoteStorage: RemoteStorageAdapter;
localStorage: LocalStorageAdapter;
watchAttachments: (onUpdate: (attachment: WatchedAttachmentItem[]) => Promise<void>, 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<AttachmentQueueRow, string>;

constructor(params: TanStackDBAttachmentQueueParams) {
super(params);
this.powersync = params.db;
this.collection = params.attachmentsCollection;
}

async saveFileTanStack({
data,
fileExtension,
mediaType,
metaData,
id,
updateHook
}: SaveFileTanStackOptions): Promise<AttachmentQueueRow> {
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<void> {
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();
});
}
}
Loading
Loading