Skip to content

Commit add45fb

Browse files
feat(tanstack-start): Quickstart and client for TanStack Start
1 parent f7a2201 commit add45fb

35 files changed

+4954
-227
lines changed

crates/bindings-typescript/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@
7777
"import": "./dist/vue/index.mjs",
7878
"require": "./dist/vue/index.cjs",
7979
"default": "./dist/vue/index.mjs"
80+
},
81+
"./tanstack": {
82+
"types": "./dist/tanstack/index.d.ts",
83+
"import": "./dist/tanstack/index.mjs",
84+
"require": "./dist/tanstack/index.cjs",
85+
"default": "./dist/tanstack/index.mjs"
8086
}
8187
},
8288
"size-limit": [
@@ -166,11 +172,15 @@
166172
"url-polyfill": "^1.1.14"
167173
},
168174
"peerDependencies": {
175+
"@tanstack/react-query": "^5.0.0",
169176
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0",
170177
"vue": "^3.3.0",
171178
"undici": "^6.19.2"
172179
},
173180
"peerDependenciesMeta": {
181+
"@tanstack/react-query": {
182+
"optional": true
183+
},
174184
"react": {
175185
"optional": true
176186
},
@@ -184,6 +194,7 @@
184194
"devDependencies": {
185195
"@eslint/js": "^9.17.0",
186196
"@size-limit/file": "^11.2.0",
197+
"@tanstack/react-query": "^5.90.19",
187198
"@types/fast-text-encoding": "^1.0.3",
188199
"@types/react": "^19.1.13",
189200
"@types/statuses": "^2.0.6",

crates/bindings-typescript/src/react/useTable.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ function classifyMembership<
196196
* Extracts the column names from a RowType whose values are of type Value.
197197
* Note that this will exclude columns that are of type object, array, etc.
198198
*/
199-
type ColumnsFromRow<R> = {
199+
export type ColumnsFromRow<R> = {
200200
[K in keyof R]-?: R[K] extends Value | undefined ? K : never;
201201
}[keyof R] &
202202
string;
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import type {
2+
QueryClient,
3+
QueryKey,
4+
QueryFunction,
5+
} from '@tanstack/react-query';
6+
import type { UntypedTableDef, RowType } from '../lib/table';
7+
import {
8+
type Expr,
9+
type ColumnsFromRow,
10+
evaluate,
11+
toString,
12+
} from '../react/useTable';
13+
14+
const tableRegistry = new Map<string, UntypedTableDef>();
15+
const whereRegistry = new Map<string, Expr<any>>();
16+
17+
export interface SpacetimeDBQueryOptions {
18+
queryKey: readonly ['spacetimedb', string, string];
19+
staleTime: number;
20+
}
21+
22+
export interface SpacetimeDBQueryOptionsSkipped
23+
extends SpacetimeDBQueryOptions {
24+
enabled: false;
25+
}
26+
27+
// creates query options for useQuery/useSuspenseQuery.
28+
// useQuery(spacetimeDBQuery(tables.person));
29+
// useQuery(spacetimeDBQuery(tables.user, where(eq('role', 'admin'))));
30+
// useQuery(spacetimeDBQuery(tables.user, userId ? where(eq('id', userId)) : 'skip'));
31+
export function spacetimeDBQuery<TableDef extends UntypedTableDef>(
32+
table: TableDef,
33+
whereOrSkip: 'skip'
34+
): SpacetimeDBQueryOptionsSkipped;
35+
36+
export function spacetimeDBQuery<TableDef extends UntypedTableDef>(
37+
table: TableDef,
38+
where?: Expr<ColumnsFromRow<RowType<TableDef>>>
39+
): SpacetimeDBQueryOptions;
40+
41+
export function spacetimeDBQuery<TableDef extends UntypedTableDef>(
42+
table: TableDef,
43+
whereOrSkip?: Expr<ColumnsFromRow<RowType<TableDef>>> | 'skip'
44+
): SpacetimeDBQueryOptions | SpacetimeDBQueryOptionsSkipped {
45+
tableRegistry.set(table.name, table);
46+
47+
if (whereOrSkip === 'skip') {
48+
return {
49+
queryKey: ['spacetimedb', table.name, 'skip'] as const,
50+
staleTime: Infinity,
51+
enabled: false,
52+
};
53+
}
54+
55+
const where = whereOrSkip;
56+
const whereStr = where ? toString(table, where) : '';
57+
58+
if (where) {
59+
const whereKey = `${table.name}:${whereStr}`;
60+
whereRegistry.set(whereKey, where);
61+
}
62+
63+
return {
64+
queryKey: ['spacetimedb', table.name, whereStr] as const,
65+
staleTime: Infinity,
66+
};
67+
}
68+
69+
interface SpacetimeConnection {
70+
db: Record<string, any>;
71+
subscriptionBuilder: () => {
72+
onApplied: (cb: () => void) => any;
73+
subscribe: (query: string) => { unsubscribe: () => void };
74+
};
75+
}
76+
77+
interface SubscriptionState {
78+
unsubscribe: () => void;
79+
tableInstance: any;
80+
applied: boolean;
81+
}
82+
83+
// push updates to cache via setQueryData when SpacetimeDB data changes
84+
export class SpacetimeDBQueryClient {
85+
private connection: SpacetimeConnection | null = null;
86+
private queryClient: QueryClient | null = null;
87+
private subscriptions = new Map<string, SubscriptionState>();
88+
private pendingQueries = new Map<
89+
string,
90+
Array<{
91+
resolve: (data: any[]) => void;
92+
tableDef: any;
93+
whereClause?: Expr<any>;
94+
}>
95+
>();
96+
private cacheUnsubscribe: (() => void) | null = null;
97+
98+
// set connection, called on onConnect callback
99+
setConnection(connection: SpacetimeConnection): void {
100+
this.connection = connection;
101+
this.processPendingQueries();
102+
}
103+
104+
connect(queryClient: QueryClient): void {
105+
this.queryClient = queryClient;
106+
107+
this.cacheUnsubscribe = queryClient.getQueryCache().subscribe(event => {
108+
if (
109+
event.type === 'removed' &&
110+
event.query.queryKey[0] === 'spacetimedb'
111+
) {
112+
const keyStr = JSON.stringify(event.query.queryKey);
113+
const sub = this.subscriptions.get(keyStr);
114+
if (sub) {
115+
sub.unsubscribe();
116+
this.subscriptions.delete(keyStr);
117+
}
118+
}
119+
});
120+
}
121+
122+
queryFn: QueryFunction<any[], QueryKey> = async ({ queryKey }) => {
123+
const keyStr = JSON.stringify(queryKey);
124+
const [prefix, tableName, whereStr] = queryKey as [string, string, string];
125+
126+
if (prefix !== 'spacetimedb') {
127+
throw new Error(
128+
`SpacetimeDBQueryClient can only handle spacetimedb queries, got: ${prefix}`
129+
);
130+
}
131+
132+
const tableDef = tableRegistry.get(tableName);
133+
const whereKey = `${tableName}:${whereStr}`;
134+
const whereClause = whereStr ? whereRegistry.get(whereKey) : undefined;
135+
136+
const existingSub = this.subscriptions.get(keyStr);
137+
if (existingSub?.applied) {
138+
return this.getTableData(existingSub.tableInstance, whereClause);
139+
}
140+
141+
// queue query if connection not ready yet
142+
if (!this.connection) {
143+
return new Promise<any[]>(resolve => {
144+
const pending = this.pendingQueries.get(keyStr) || [];
145+
pending.push({ resolve, tableDef, whereClause });
146+
this.pendingQueries.set(keyStr, pending);
147+
});
148+
}
149+
150+
return this.setupSubscription(queryKey, tableName, tableDef, whereClause);
151+
};
152+
153+
private getTableData(tableInstance: any, whereClause?: Expr<any>): any[] {
154+
const allRows = Array.from(tableInstance.iter());
155+
if (whereClause) {
156+
return allRows.filter(row =>
157+
evaluate(whereClause, row as Record<string, unknown>)
158+
);
159+
}
160+
return allRows;
161+
}
162+
163+
private setupSubscription(
164+
queryKey: QueryKey,
165+
tableName: string,
166+
tableDef: any,
167+
whereClause?: Expr<any>
168+
): Promise<any[]> {
169+
if (!this.connection) {
170+
return Promise.resolve([]);
171+
}
172+
173+
const keyStr = JSON.stringify(queryKey);
174+
const db = this.connection.db;
175+
176+
const accessorName = tableDef?.accessorName ?? tableName;
177+
const tableInstance = db[accessorName];
178+
179+
if (!tableInstance) {
180+
console.warn(
181+
`SpacetimeDBQueryClient: table "${tableName}" (accessor: ${accessorName}) not found`
182+
);
183+
return Promise.resolve([]);
184+
}
185+
186+
// return existing data if already subscribed
187+
const existingSub = this.subscriptions.get(keyStr);
188+
if (existingSub) {
189+
if (existingSub.applied) {
190+
return Promise.resolve(
191+
this.getTableData(existingSub.tableInstance, whereClause)
192+
);
193+
}
194+
return new Promise(resolve => {
195+
const pending = this.pendingQueries.get(keyStr) || [];
196+
pending.push({ resolve, tableDef, whereClause });
197+
this.pendingQueries.set(keyStr, pending);
198+
});
199+
}
200+
201+
const query =
202+
`SELECT * FROM ${tableName}` +
203+
(whereClause && tableDef
204+
? ` WHERE ${toString(tableDef, whereClause as any)}`
205+
: '');
206+
207+
return new Promise<any[]>(resolve => {
208+
const updateCache = () => {
209+
if (!this.queryClient) return [];
210+
const data = this.getTableData(tableInstance, whereClause);
211+
this.queryClient.setQueryData(queryKey, data);
212+
return data;
213+
};
214+
215+
const handle = this.connection!.subscriptionBuilder()
216+
.onApplied(() => {
217+
const sub = this.subscriptions.get(keyStr);
218+
if (sub) {
219+
sub.applied = true;
220+
}
221+
222+
const data = updateCache();
223+
resolve(data);
224+
225+
const pending = this.pendingQueries.get(keyStr);
226+
if (pending) {
227+
for (const p of pending) {
228+
p.resolve(data);
229+
}
230+
this.pendingQueries.delete(keyStr);
231+
}
232+
})
233+
.subscribe(query);
234+
235+
// push updates to cache when data changes
236+
const onTableChange = () => {
237+
const sub = this.subscriptions.get(keyStr);
238+
if (sub?.applied) {
239+
updateCache();
240+
}
241+
};
242+
243+
tableInstance.onInsert(onTableChange);
244+
tableInstance.onDelete(onTableChange);
245+
tableInstance.onUpdate?.(onTableChange);
246+
247+
this.subscriptions.set(keyStr, {
248+
unsubscribe: () => {
249+
handle.unsubscribe();
250+
tableInstance.removeOnInsert(onTableChange);
251+
tableInstance.removeOnDelete(onTableChange);
252+
tableInstance.removeOnUpdate?.(onTableChange);
253+
},
254+
tableInstance,
255+
applied: false,
256+
});
257+
});
258+
}
259+
260+
private processPendingQueries(): void {
261+
if (!this.connection) return;
262+
263+
const pendingEntries = Array.from(this.pendingQueries.entries());
264+
this.pendingQueries.clear();
265+
266+
for (const [keyStr, pending] of pendingEntries) {
267+
const queryKey = JSON.parse(keyStr) as QueryKey;
268+
const [, tableName] = queryKey as [string, string, string];
269+
270+
if (pending.length > 0) {
271+
const first = pending[0];
272+
this.setupSubscription(
273+
queryKey,
274+
tableName,
275+
first.tableDef,
276+
first.whereClause
277+
)
278+
.then(data => {
279+
for (const p of pending) {
280+
p.resolve(data);
281+
}
282+
})
283+
.catch(() => {
284+
for (const p of pending) {
285+
p.resolve([]);
286+
}
287+
});
288+
}
289+
}
290+
}
291+
292+
// clean up all subscriptions and disconnect
293+
disconnect(): void {
294+
if (this.cacheUnsubscribe) {
295+
this.cacheUnsubscribe();
296+
this.cacheUnsubscribe = null;
297+
}
298+
299+
for (const sub of this.subscriptions.values()) {
300+
sub.unsubscribe();
301+
}
302+
this.subscriptions.clear();
303+
this.pendingQueries.clear();
304+
this.connection = null;
305+
}
306+
}

0 commit comments

Comments
 (0)