Skip to content

Commit d7a150d

Browse files
kevin-dpclaude
andcommitted
feat(examples): add wa-sqlite OPFS persistence demo to offline-transactions
Add a new /wa-sqlite route to the offline-transactions example that demonstrates collection-level persistence using wa-sqlite with OPFS. Unlike the existing IndexedDB/localStorage pages (which use offline executor mutation queuing), this uses persistedCollectionOptions for local-first data storage in a real SQLite database in the browser. - Add PersistedTodoDemo component with CRUD operations - Add persisted-todos.ts collection setup using wa-sqlite OPFS - Configure Vite to alias wa-sqlite package to source for ?worker imports - Add WASM middleware to serve .wasm files before TanStack Start's catch-all - Fix router.tsx missing getRouter export required by TanStack Start Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 49720f6 commit d7a150d

10 files changed

Lines changed: 483 additions & 6 deletions

File tree

examples/react/offline-transactions/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"start": "node .output/server/index.mjs"
1010
},
1111
"dependencies": {
12+
"@journeyapps/wa-sqlite": "^1.4.1",
13+
"@tanstack/db": "workspace:*",
14+
"@tanstack/db-browser-wa-sqlite-persisted-collection": "workspace:*",
1215
"@tanstack/offline-transactions": "^1.0.21",
1316
"@tanstack/query-db-collection": "^1.0.27",
1417
"@tanstack/react-db": "^0.1.74",
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import React, { useState } from 'react'
2+
import { useLiveQuery } from '@tanstack/react-db'
3+
import type { Collection } from '@tanstack/db'
4+
import type { PersistedTodo } from '~/db/persisted-todos'
5+
6+
interface PersistedTodoDemoProps {
7+
collection: Collection<PersistedTodo, string>
8+
}
9+
10+
export function PersistedTodoDemo({ collection }: PersistedTodoDemoProps) {
11+
const [newTodoText, setNewTodoText] = useState(``)
12+
const [error, setError] = useState<string | null>(null)
13+
14+
const { data: todoList = [] } = useLiveQuery((q) =>
15+
q
16+
.from({ todo: collection })
17+
.orderBy(({ todo }) => todo.createdAt, `desc`),
18+
)
19+
20+
const handleAddTodo = () => {
21+
if (!newTodoText.trim()) return
22+
23+
try {
24+
setError(null)
25+
const now = new Date().toISOString()
26+
collection.insert({
27+
id: crypto.randomUUID(),
28+
text: newTodoText.trim(),
29+
completed: false,
30+
createdAt: now,
31+
updatedAt: now,
32+
})
33+
setNewTodoText(``)
34+
} catch (err) {
35+
setError(err instanceof Error ? err.message : `Failed to add todo`)
36+
}
37+
}
38+
39+
const handleToggleTodo = (id: string) => {
40+
try {
41+
setError(null)
42+
collection.update(id, (draft) => {
43+
draft.completed = !draft.completed
44+
draft.updatedAt = new Date().toISOString()
45+
})
46+
} catch (err) {
47+
setError(err instanceof Error ? err.message : `Failed to toggle todo`)
48+
}
49+
}
50+
51+
const handleDeleteTodo = (id: string) => {
52+
try {
53+
setError(null)
54+
collection.delete(id)
55+
} catch (err) {
56+
setError(err instanceof Error ? err.message : `Failed to delete todo`)
57+
}
58+
}
59+
60+
const handleKeyPress = (e: React.KeyboardEvent) => {
61+
if (e.key === `Enter`) {
62+
handleAddTodo()
63+
}
64+
}
65+
66+
return (
67+
<div className="max-w-2xl mx-auto p-6">
68+
<div className="bg-white rounded-lg shadow-lg p-6">
69+
<div className="flex items-center gap-3 mb-4">
70+
<span className="text-2xl">🗃️</span>
71+
<div>
72+
<h2 className="text-2xl font-bold text-gray-900">
73+
wa-sqlite OPFS Persistence Demo
74+
</h2>
75+
<p className="text-gray-600">
76+
Collection data is persisted to SQLite via OPFS. Data survives
77+
page reloads without any server sync.
78+
</p>
79+
</div>
80+
</div>
81+
82+
{/* Persistence indicator */}
83+
<div className="flex flex-wrap gap-4 mb-6 text-sm">
84+
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-100 text-emerald-800">
85+
<div className="w-2 h-2 rounded-full bg-emerald-500" />
86+
SQLite OPFS Persistence Active
87+
</div>
88+
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-gray-100 text-gray-600">
89+
{todoList.length} todo{todoList.length !== 1 ? `s` : ``}
90+
</div>
91+
</div>
92+
93+
{/* Error display */}
94+
{error && (
95+
<div className="mb-4 p-3 bg-red-100 border border-red-300 rounded-md">
96+
<p className="text-red-700 text-sm">{error}</p>
97+
</div>
98+
)}
99+
100+
{/* Add new todo */}
101+
<div className="flex gap-2 mb-6">
102+
<input
103+
type="text"
104+
value={newTodoText}
105+
onChange={(e) => setNewTodoText(e.target.value)}
106+
onKeyPress={handleKeyPress}
107+
placeholder="Add a new todo..."
108+
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
109+
/>
110+
<button
111+
onClick={handleAddTodo}
112+
disabled={!newTodoText.trim()}
113+
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
114+
>
115+
Add
116+
</button>
117+
</div>
118+
119+
{/* Todo list */}
120+
<div className="space-y-2">
121+
{todoList.length === 0 ? (
122+
<div className="text-center py-8 text-gray-500">
123+
No todos yet. Add one above to get started!
124+
<br />
125+
<span className="text-xs">
126+
Try adding todos, then refresh the page to see them persist
127+
</span>
128+
</div>
129+
) : (
130+
todoList.map((todo) => (
131+
<div
132+
key={todo.id}
133+
className="flex items-center gap-3 p-3 border border-gray-200 rounded-md hover:bg-gray-50"
134+
>
135+
<button
136+
onClick={() => handleToggleTodo(todo.id)}
137+
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
138+
todo.completed
139+
? `bg-green-500 border-green-500 text-white`
140+
: `border-gray-300 hover:border-green-400`
141+
}`}
142+
>
143+
{todo.completed && <span className="text-xs"></span>}
144+
</button>
145+
<span
146+
className={`flex-1 ${
147+
todo.completed
148+
? `line-through text-gray-500`
149+
: `text-gray-900`
150+
}`}
151+
>
152+
{todo.text}
153+
</span>
154+
<span className="text-xs text-gray-400">
155+
{new Date(todo.createdAt).toLocaleDateString()}
156+
</span>
157+
<button
158+
onClick={() => handleDeleteTodo(todo.id)}
159+
className="px-2 py-1 text-red-600 hover:bg-red-50 rounded text-sm"
160+
>
161+
Delete
162+
</button>
163+
</div>
164+
))
165+
)}
166+
</div>
167+
168+
{/* Instructions */}
169+
<div className="mt-6 p-4 bg-gray-50 rounded-md">
170+
<h3 className="font-medium text-gray-900 mb-2">Try this:</h3>
171+
<ol className="text-sm text-gray-600 space-y-1">
172+
<li>1. Add some todos</li>
173+
<li>2. Refresh the page (Ctrl+R / Cmd+R)</li>
174+
<li>3. Your todos are still here - persisted in SQLite via OPFS!</li>
175+
<li>
176+
4. This uses wa-sqlite with OPFSCoopSyncVFS in a Web Worker
177+
</li>
178+
</ol>
179+
</div>
180+
</div>
181+
</div>
182+
)
183+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createCollection } from '@tanstack/react-db'
2+
import {
3+
BrowserCollectionCoordinator,
4+
createBrowserWASQLitePersistence,
5+
openBrowserWASQLiteOPFSDatabase,
6+
persistedCollectionOptions,
7+
} from '@tanstack/db-browser-wa-sqlite-persisted-collection'
8+
import type { Collection } from '@tanstack/db'
9+
10+
export type PersistedTodo = {
11+
id: string
12+
text: string
13+
completed: boolean
14+
createdAt: string
15+
updatedAt: string
16+
}
17+
18+
export type PersistedTodosHandle = {
19+
collection: Collection<PersistedTodo, string>
20+
close: () => Promise<void>
21+
}
22+
23+
export async function createPersistedTodoCollection(): Promise<PersistedTodosHandle> {
24+
const database = await openBrowserWASQLiteOPFSDatabase({
25+
databaseName: `tanstack-db-demo-v2.sqlite`,
26+
})
27+
28+
const coordinator = new BrowserCollectionCoordinator({
29+
dbName: `tanstack-db-demo`,
30+
})
31+
32+
const persistence = createBrowserWASQLitePersistence({
33+
database,
34+
coordinator,
35+
})
36+
37+
const collection = createCollection<PersistedTodo, string>(
38+
persistedCollectionOptions<PersistedTodo, string>({
39+
id: `persisted-todos`,
40+
getKey: (todo) => todo.id,
41+
persistence,
42+
schemaVersion: 1,
43+
}),
44+
)
45+
46+
return {
47+
collection,
48+
close: async () => {
49+
coordinator.dispose()
50+
await database.close?.()
51+
},
52+
}
53+
}

examples/react/offline-transactions/src/routeTree.gen.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@
99
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010

1111
import { Route as rootRouteImport } from './routes/__root'
12+
import { Route as WaSqliteRouteImport } from './routes/wa-sqlite'
1213
import { Route as LocalstorageRouteImport } from './routes/localstorage'
1314
import { Route as IndexeddbRouteImport } from './routes/indexeddb'
1415
import { Route as IndexRouteImport } from './routes/index'
1516

17+
const WaSqliteRoute = WaSqliteRouteImport.update({
18+
id: '/wa-sqlite',
19+
path: '/wa-sqlite',
20+
getParentRoute: () => rootRouteImport,
21+
} as any)
1622
const LocalstorageRoute = LocalstorageRouteImport.update({
1723
id: '/localstorage',
1824
path: '/localstorage',
@@ -33,34 +39,45 @@ export interface FileRoutesByFullPath {
3339
'/': typeof IndexRoute
3440
'/indexeddb': typeof IndexeddbRoute
3541
'/localstorage': typeof LocalstorageRoute
42+
'/wa-sqlite': typeof WaSqliteRoute
3643
}
3744
export interface FileRoutesByTo {
3845
'/': typeof IndexRoute
3946
'/indexeddb': typeof IndexeddbRoute
4047
'/localstorage': typeof LocalstorageRoute
48+
'/wa-sqlite': typeof WaSqliteRoute
4149
}
4250
export interface FileRoutesById {
4351
__root__: typeof rootRouteImport
4452
'/': typeof IndexRoute
4553
'/indexeddb': typeof IndexeddbRoute
4654
'/localstorage': typeof LocalstorageRoute
55+
'/wa-sqlite': typeof WaSqliteRoute
4756
}
4857
export interface FileRouteTypes {
4958
fileRoutesByFullPath: FileRoutesByFullPath
50-
fullPaths: '/' | '/indexeddb' | '/localstorage'
59+
fullPaths: '/' | '/indexeddb' | '/localstorage' | '/wa-sqlite'
5160
fileRoutesByTo: FileRoutesByTo
52-
to: '/' | '/indexeddb' | '/localstorage'
53-
id: '__root__' | '/' | '/indexeddb' | '/localstorage'
61+
to: '/' | '/indexeddb' | '/localstorage' | '/wa-sqlite'
62+
id: '__root__' | '/' | '/indexeddb' | '/localstorage' | '/wa-sqlite'
5463
fileRoutesById: FileRoutesById
5564
}
5665
export interface RootRouteChildren {
5766
IndexRoute: typeof IndexRoute
5867
IndexeddbRoute: typeof IndexeddbRoute
5968
LocalstorageRoute: typeof LocalstorageRoute
69+
WaSqliteRoute: typeof WaSqliteRoute
6070
}
6171

6272
declare module '@tanstack/react-router' {
6373
interface FileRoutesByPath {
74+
'/wa-sqlite': {
75+
id: '/wa-sqlite'
76+
path: '/wa-sqlite'
77+
fullPath: '/wa-sqlite'
78+
preLoaderRoute: typeof WaSqliteRouteImport
79+
parentRoute: typeof rootRouteImport
80+
}
6481
'/localstorage': {
6582
id: '/localstorage'
6683
path: '/localstorage'
@@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = {
89106
IndexRoute: IndexRoute,
90107
IndexeddbRoute: IndexeddbRoute,
91108
LocalstorageRoute: LocalstorageRoute,
109+
WaSqliteRoute: WaSqliteRoute,
92110
}
93111
export const routeTree = rootRouteImport
94112
._addFileChildren(rootRouteChildren)

examples/react/offline-transactions/src/router.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export function createRouter() {
1515
return router
1616
}
1717

18+
export async function getRouter() {
19+
return createRouter()
20+
}
21+
1822
declare module '@tanstack/react-router' {
1923
interface Register {
2024
router: ReturnType<typeof createRouter>

examples/react/offline-transactions/src/routes/__root.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ function RootDocument({ children }: { children: React.ReactNode }) {
109109
>
110110
💾 localStorage
111111
</Link>
112+
<Link
113+
to="/wa-sqlite"
114+
activeProps={{
115+
className: `font-bold text-blue-600`,
116+
}}
117+
className="text-gray-600 hover:text-gray-900"
118+
>
119+
🗃️ wa-sqlite
120+
</Link>
112121
</div>
113122
</div>
114123
</div>

examples/react/offline-transactions/src/routes/index.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,43 @@ function Home() {
7373
</Link>
7474
</div>
7575

76+
<div className="mb-8">
77+
<Link to="/wa-sqlite">
78+
<div className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow cursor-pointer group border-2 border-emerald-200">
79+
<div className="flex items-center mb-4">
80+
<span className="text-3xl mr-3">🗃️</span>
81+
<div>
82+
<h2 className="text-2xl font-bold text-gray-900 group-hover:text-emerald-600">
83+
wa-sqlite OPFS Persistence
84+
</h2>
85+
<span className="text-xs px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full">
86+
NEW
87+
</span>
88+
</div>
89+
</div>
90+
<p className="text-gray-600 mb-4">
91+
Collection-level persistence using wa-sqlite with OPFS. Data is
92+
stored in a real SQLite database in the browser via a Web
93+
Worker. Survives page reloads without server sync.
94+
</p>
95+
<div className="flex flex-wrap gap-2">
96+
<span className="px-2 py-1 bg-emerald-100 text-emerald-800 text-xs rounded">
97+
SQLite in Browser
98+
</span>
99+
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">
100+
OPFS Storage
101+
</span>
102+
<span className="px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded">
103+
Web Worker
104+
</span>
105+
<span className="px-2 py-1 bg-orange-100 text-orange-800 text-xs rounded">
106+
Local-only
107+
</span>
108+
</div>
109+
</div>
110+
</Link>
111+
</div>
112+
76113
<div className="bg-white rounded-lg shadow-lg p-8">
77114
<h2 className="text-2xl font-bold text-gray-900 mb-6">
78115
Features Demonstrated

0 commit comments

Comments
 (0)