Skip to content

Commit 99edb29

Browse files
feat: Quickstart and client for TanStack Start (#4107)
# Description of Changes - Quickstart template `tanstack-ts` and client for TanStack Start - Add Vue Quickstart Docs <!-- Please describe your change, mention any related tickets, and so on here. --> # Screenshots - `tanstack-ts` template <img width="1461" height="898" alt="image" src="https://github.com/user-attachments/assets/6b7e5473-33c4-4f76-92a7-18607c74422c" /> - TanStack Start Quickstart Docs <img width="1459" height="896" alt="image" src="https://github.com/user-attachments/assets/b7557498-ff5a-4ce5-9c85-68db943e1b9a" /> # API and ABI breaking changes <!-- If this is an API or ABI breaking change, please apply the corresponding GitHub label. --> # Expected complexity level and risk <!-- How complicated do you think these changes are? Grade on a scale from 1 to 5, where 1 is a trivial change, and 5 is a deep-reaching and complex change. This complexity rating applies not only to the complexity apparent in the diff, but also to its interactions with existing and future code. If you answered more than a 2, explain what is complex about the PR, and what other components it interacts with in potentially concerning ways. --> # Testing <!-- Describe any testing you've done, and any testing you'd like your reviewers to do, so that you're confident that all the changes work as expected! --> - [x] Tested the templates locally (e.g. able to add people), works well for me
1 parent 9143384 commit 99edb29

33 files changed

Lines changed: 2726 additions & 665 deletions

crates/bindings-typescript/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@
7777
"require": "./dist/vue/index.cjs",
7878
"default": "./dist/vue/index.mjs"
7979
},
80+
"./tanstack": {
81+
"types": "./dist/tanstack/index.d.ts",
82+
"import": "./dist/tanstack/index.mjs",
83+
"require": "./dist/tanstack/index.cjs",
84+
"default": "./dist/tanstack/index.mjs"
85+
},
8086
"./svelte": {
8187
"types": "./dist/svelte/index.d.ts",
8288
"import": "./dist/svelte/index.mjs",
@@ -172,12 +178,16 @@
172178
"url-polyfill": "^1.1.14"
173179
},
174180
"peerDependencies": {
181+
"@tanstack/react-query": "^5.0.0",
175182
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0",
176183
"vue": "^3.3.0",
177184
"svelte": "^4.0.0 || ^5.0.0",
178185
"undici": "^6.19.2"
179186
},
180187
"peerDependenciesMeta": {
188+
"@tanstack/react-query": {
189+
"optional": true
190+
},
181191
"react": {
182192
"optional": true
183193
},
@@ -195,6 +205,7 @@
195205
"@eslint/js": "^9.17.0",
196206
"svelte": "^5.0.0",
197207
"@size-limit/file": "^11.2.0",
208+
"@tanstack/react-query": "^5.90.19",
198209
"@types/fast-text-encoding": "^1.0.3",
199210
"@types/react": "^19.1.13",
200211
"@types/statuses": "^2.0.6",
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 '../lib/filter';
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)