Skip to content

Commit 58c15a2

Browse files
poc tanstackdb attachments
1 parent 5f65b5f commit 58c15a2

10 files changed

Lines changed: 293 additions & 24 deletions

File tree

demos/react-supabase-todolist-tanstackdb/pnpm-workspace.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ allowBuilds:
44
'@journeyapps/wa-sqlite': true
55
'@swc/core': true
66
esbuild: true
7+
8+
trustPolicyExclude:
9+
- rollup@2.80.0
10+
- semver@6.3.1

demos/react-supabase-todolist-tanstackdb/src/app/views/todo-lists/page.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NavigationPage } from '@/components/navigation/NavigationPage';
2-
import { listsCollection, useSupabase } from '@/components/providers/SystemProvider';
2+
import { attachmentQueue, listsCollection, useSupabase } from '@/components/providers/SystemProvider';
33
import { GuardBySync } from '@/components/widgets/GuardBySync';
44
import { SearchBarWidget } from '@/components/widgets/SearchBarWidget';
55
import { TodoListsWidget } from '@/components/widgets/TodoListsWidget';
@@ -31,13 +31,21 @@ export default function TodoListsPage() {
3131
throw new Error(`Could not create new lists, no userID found`);
3232
}
3333

34-
// This could alternatively be synchronous and use optimistic updates
35-
await listsCollection.insert({
36-
id: crypto.randomUUID(),
37-
name,
38-
created_at: new Date(),
39-
owner_id: userID
40-
}).isPersisted.promise;
34+
await attachmentQueue.saveFileTanStack({
35+
// This is just random file data for this poc, this could be an image from a camera etc
36+
data: btoa(crypto.randomUUID()),
37+
fileExtension: 'jpg',
38+
updateHook: async (attachmentRecord) => {
39+
// This should happen in the same transaction as creating the attachment
40+
listsCollection.insert({
41+
id: crypto.randomUUID(),
42+
name,
43+
created_at: new Date(),
44+
owner_id: userID,
45+
photo_id: attachmentRecord.id // make the association for related data
46+
});
47+
}
48+
});
4149
};
4250

4351
return (
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { AppSchema } from '@/library/powersync/AppSchema';
2+
import {
3+
AbstractPowerSyncDatabase,
4+
AttachmentData,
5+
AttachmentErrorHandler,
6+
AttachmentQueue,
7+
AttachmentRecord,
8+
AttachmentService,
9+
AttachmentState,
10+
ILogger,
11+
IndexDBFileSystemStorageAdapter,
12+
LocalStorageAdapter,
13+
RemoteStorageAdapter,
14+
WatchedAttachmentItem
15+
} from '@powersync/web';
16+
import { Collection, createTransaction } from '@tanstack/db';
17+
import { PowerSyncTransactor } from '@tanstack/powersync-db-collection';
18+
19+
export const LocalAttachmentStoage = new IndexDBFileSystemStorageAdapter('my-app-files');
20+
21+
export const RemoteAttachmentStorage = {
22+
async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord) {
23+
// no-op for poc
24+
},
25+
26+
async downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer> {
27+
// no-op for poc
28+
return new ArrayBuffer();
29+
},
30+
31+
async deleteFile(attachment: AttachmentRecord) {
32+
// no-op for poc
33+
}
34+
};
35+
36+
/**
37+
* This extends the default AttachmentQueue constructor params
38+
* FIXME(powersync) we should export this type from the common SDK.
39+
*/
40+
type TanStackDBAttachmentQueueParams = {
41+
db: AbstractPowerSyncDatabase;
42+
/**
43+
* For TanStack, we want access to the synced TanStackDB collection.
44+
* In order to have the same relational data be set in a single transaction.
45+
* This also allows for joining both TanStackDB collections.
46+
*/
47+
attachmentsCollection: Collection<AttachmentQueueRow>;
48+
remoteStorage: RemoteStorageAdapter;
49+
localStorage: LocalStorageAdapter;
50+
watchAttachments: (onUpdate: (attachment: WatchedAttachmentItem[]) => Promise<void>, signal: AbortSignal) => void;
51+
tableName?: string;
52+
logger?: ILogger;
53+
syncIntervalMs?: number;
54+
syncThrottleDuration?: number;
55+
downloadAttachments?: boolean;
56+
archivedCacheLimit?: number;
57+
errorHandler?: AttachmentErrorHandler;
58+
};
59+
60+
/**
61+
* The PowerSync table row type
62+
*/
63+
type AttachmentQueueRow = (typeof AppSchema)['types']['attachments'];
64+
65+
/**
66+
* A custom extension of the PowerSyncAttachmentQueue.
67+
* We could export something like this in the TanStackDB integration
68+
*/
69+
export class TanStackDBAttachmentQueue extends AttachmentQueue {
70+
readonly powersync: AbstractPowerSyncDatabase;
71+
readonly collection: Collection<AttachmentQueueRow>;
72+
73+
constructor(params: TanStackDBAttachmentQueueParams) {
74+
super(params);
75+
this.powersync = params.db;
76+
this.collection = params.attachmentsCollection;
77+
}
78+
79+
/**
80+
* HACK: The AttachmentQueue should make this protected instead,
81+
* in order for extensions to use it.
82+
*/
83+
get _attachmentService(): AttachmentService {
84+
// This is not protected, it's private and should be protected
85+
return this['attachmentService'] as AttachmentService;
86+
}
87+
88+
/**
89+
* Saves a new attachment given the input data.
90+
* Provides an updateHook which is called inside a TanStackDB transaction.
91+
* Relational associataions with the provded attachment ID should be made in this hook.
92+
*/
93+
async saveFileTanStack({
94+
data,
95+
fileExtension,
96+
mediaType,
97+
metaData,
98+
id,
99+
updateHook
100+
}: {
101+
data: AttachmentData;
102+
fileExtension: string;
103+
mediaType?: string;
104+
metaData?: string;
105+
id?: string;
106+
// Note that this is called inside a synchronous TanStackDB transaction
107+
// any mutations made to other collections, will be in the same transaction.
108+
updateHook?: (attachment: AttachmentQueueRow) => Promise<void>;
109+
}): Promise<AttachmentQueueRow> {
110+
const resolvedId = id ?? (await this.generateAttachmentId());
111+
const filename = `${resolvedId}.${fileExtension}`;
112+
const localUri = this.localStorage.getLocalUri(filename);
113+
const size = await this.localStorage.saveFile(localUri, data);
114+
115+
const attachment: AttachmentQueueRow = {
116+
id: resolvedId,
117+
filename,
118+
media_type: mediaType ?? null,
119+
local_uri: localUri,
120+
state: AttachmentState.QUEUED_UPLOAD,
121+
has_synced: 0,
122+
size,
123+
timestamp: new Date().getTime(),
124+
meta_data: metaData ?? null
125+
};
126+
127+
/**
128+
* The use the attachmentService lock to prevent potential attachment queue race conditions.
129+
* This specicifally prevents assuming a newly watched attachment record is one to download.
130+
* */
131+
await this._attachmentService.withContext(async (ctx) => {
132+
// Create a TanStackDB transaction context, the mutation will happen later
133+
const tanStackDBTransaction = createTransaction({
134+
autoCommit: false,
135+
mutationFn: async ({ transaction }) => {
136+
// Now we should apply the actual operations.
137+
// We can save the attachment using dedicated APIs
138+
await new PowerSyncTransactor({
139+
database: ctx.db
140+
}).applyTransaction(transaction);
141+
142+
// We don't need to explicitly use this here, the default transactor should
143+
// be able to handle this (but it could be more future proof if we did support it later)
144+
// await ctx.upsertAttachment(attachment, tx);
145+
}
146+
});
147+
148+
/**
149+
* TODO, does the user want to have the attachment record peristed in this transaction or not?
150+
* The implementation can be done according to the users's needs, devs should
151+
* implement this saveFile override themselves, this is just an example.
152+
*
153+
* In this example, we write the attachment record first.
154+
*/
155+
tanStackDBTransaction.mutate(() => {
156+
// save the attachment record
157+
this.collection.insert(attachment);
158+
// allow the user to associate values in this transaction
159+
updateHook?.(attachment);
160+
});
161+
162+
// Actually perform the transaction
163+
await tanStackDBTransaction.commit();
164+
});
165+
166+
return attachment;
167+
}
168+
}

demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@ import { SupabaseConnector } from '@/library/powersync/SupabaseConnector';
44
import { TodosDeserializationSchema, TodosSchema } from '@/library/powersync/TodosSchema';
55
import { CircularProgress } from '@mui/material';
66
import { PowerSyncContext } from '@powersync/react';
7-
import { createBaseLogger, LogLevel, PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web';
8-
import { createCollection } from '@tanstack/db';
7+
import {
8+
createBaseLogger,
9+
LogLevel,
10+
PowerSyncDatabase,
11+
WASQLiteOpenFactory,
12+
WASQLiteVFS,
13+
WatchedAttachmentItem
14+
} from '@powersync/web';
15+
import { createCollection, isNull, liveQueryCollectionOptions, not } from '@tanstack/db';
916
import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection';
1017
import React, { Suspense } from 'react';
1118
import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext';
19+
import { LocalAttachmentStoage, RemoteAttachmentStorage, TanStackDBAttachmentQueue } from './Attachments';
1220

1321
const SupabaseContext = React.createContext<SupabaseConnector | null>(null);
1422
export const useSupabase = () => React.useContext(SupabaseContext);
1523

1624
export const db = new PowerSyncDatabase({
1725
schema: AppSchema,
1826
database: new WASQLiteOpenFactory({
19-
dbFilename: 'example.db',
27+
dbFilename: 'example-v2.db',
2028
vfs: WASQLiteVFS.OPFSCoopSyncVFS
2129
})
2230
});
@@ -47,6 +55,68 @@ export const todosCollection = createCollection(
4755
})
4856
);
4957

58+
// Keep the local only attachment records in sync with TanStackDB
59+
export const attachmentsCollection = createCollection(
60+
powerSyncCollectionOptions({
61+
database: db,
62+
table: AppSchema.props.attachments
63+
})
64+
);
65+
66+
export const attachmentQueue = new TanStackDBAttachmentQueue({
67+
db: db, // PowerSync database instance
68+
attachmentsCollection: attachmentsCollection as any, //TODO better typing,
69+
localStorage: LocalAttachmentStoage,
70+
remoteStorage: RemoteAttachmentStorage,
71+
72+
// Define which attachments exist in your data model
73+
watchAttachments: async (onUpdate, abortSignal) => {
74+
const livePhotoIds = createCollection(
75+
liveQueryCollectionOptions({
76+
query: (q) =>
77+
q
78+
.from({ document: listsCollection })
79+
.where(({ document }) => not(isNull(document.photo_id)))
80+
.select(({ document }) => ({
81+
photo_id: document.photo_id
82+
}))
83+
})
84+
);
85+
86+
const initialState = await livePhotoIds.stateWhenReady();
87+
88+
type LivePhotoId = { photo_id: string | null };
89+
const mapper = (item: Partial<LivePhotoId>) =>
90+
({ id: item.photo_id!, fileExtension: 'jpg' }) satisfies WatchedAttachmentItem;
91+
92+
// report the initial state of all active attachment IDs
93+
onUpdate(Array.from(initialState.values()).map(mapper));
94+
95+
// Subscribe for future changes
96+
livePhotoIds.subscribeChanges((changes) => {
97+
// we need the wholistic state for at every change
98+
const allPhotoIds = livePhotoIds.map(mapper);
99+
onUpdate(allPhotoIds);
100+
});
101+
102+
abortSignal.addEventListener(
103+
'abort',
104+
() => {
105+
// Stop the watched operations
106+
livePhotoIds.cleanup();
107+
},
108+
{ once: true }
109+
);
110+
},
111+
112+
// Optional configuration
113+
syncIntervalMs: 30000, // Sync every 30 seconds
114+
downloadAttachments: true, // Auto-download referenced files
115+
archivedCacheLimit: 100 // Keep 100 archived files before cleanup
116+
});
117+
118+
attachmentQueue.startSync();
119+
50120
export type EnhancedListRecord = ListRecord & { total_tasks: number; completed_tasks: number };
51121

52122
export const SystemProvider = ({ children }: { children: React.ReactNode }) => {

demos/react-supabase-todolist-tanstackdb/src/components/widgets/ListItemWidget.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ export type ListItemWidgetProps = {
2525
id: string;
2626
title: string;
2727
description: string;
28+
localUri?: string | null;
2829
selected?: boolean;
2930
};
3031

3132
export const ListItemWidget: React.FC<ListItemWidgetProps> = React.memo((props) => {
32-
const { id, title, description, selected } = props;
33+
const { id, title, description, localUri, selected } = props;
3334

3435
const navigate = useNavigate();
3536

@@ -79,14 +80,24 @@ export const ListItemWidget: React.FC<ListItemWidgetProps> = React.memo((props)
7980
<RightIcon />
8081
</IconButton>
8182
</Box>
82-
}>
83+
}
84+
>
8385
<ListItemButton onClick={openList} selected={selected}>
8486
<ListItemAvatar>
8587
<Avatar>
8688
<ListIcon />
8789
</Avatar>
8890
</ListItemAvatar>
89-
<ListItemText primary={title} secondary={description} />
91+
<ListItemText
92+
primary={title}
93+
secondary={
94+
<>
95+
{description}
96+
<br />
97+
local_uri: {localUri ?? 'none'}
98+
</>
99+
}
100+
/>
90101
</ListItemButton>
91102
</ListItem>
92103
</S.MainPaper>

demos/react-supabase-todolist-tanstackdb/src/components/widgets/SearchBarWidget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const SearchBarWidget: React.FC<any> = () => {
2525
q
2626
.from({ todos: todosCollection })
2727
.where(({ todos }) => like(todos.description, `%${searchInput}%`))
28-
.join({ lists: listsCollection }, ({ todos, lists }) => eq(todos.list_id, lists.id))
28+
.innerJoin({ lists: listsCollection }, ({ todos, lists }) => eq(todos.list_id, lists.id))
2929
.select(({ todos, lists }) => ({
3030
id: todos.id,
3131
list_id: todos.list_id,

demos/react-supabase-todolist-tanstackdb/src/components/widgets/TodoListsWidget.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { List } from '@mui/material';
22
import { count, eq, sum, useLiveQuery } from '@tanstack/react-db';
3-
import { listsCollection, todosCollection } from '../providers/SystemProvider';
3+
import { attachmentsCollection, listsCollection, todosCollection } from '../providers/SystemProvider';
44
import { ListItemWidget } from './ListItemWidget';
55

66
export type TodoListsWidgetProps = {
@@ -16,10 +16,12 @@ export function TodoListsWidget(props: TodoListsWidgetProps) {
1616
q
1717
.from({ lists: listsCollection })
1818
.leftJoin({ todos: todosCollection }, ({ lists, todos }) => eq(lists.id, todos.list_id))
19-
.groupBy(({ lists }) => [lists.id, lists.name])
20-
.select(({ lists, todos }) => ({
19+
.leftJoin({ attachment: attachmentsCollection }, ({ lists, attachment }) => eq(lists.photo_id, attachment.id))
20+
.groupBy(({ lists, attachment }) => [lists.id, lists.name, attachment.local_uri])
21+
.select(({ lists, todos, attachment }) => ({
2122
id: lists.id,
2223
name: lists.name,
24+
attachment_local_uri: attachment?.local_uri,
2325
total_tasks: count(todos?.id),
2426
completed_tasks: sum(todos?.completed as number)
2527
}))
@@ -41,6 +43,7 @@ export function TodoListsWidget(props: TodoListsWidgetProps) {
4143
id={r.id}
4244
title={r.name ?? ''}
4345
description={description(r.total_tasks, r.completed_tasks)}
46+
localUri={r.attachment_local_uri}
4447
selected={r.id == props.selectedId}
4548
/>
4649
))}

0 commit comments

Comments
 (0)