Skip to content

Commit e0a59cd

Browse files
kevin-dpclaudeautofix-ci[bot]
authored
feat(examples): add wa-sqlite OPFS persistence demo (#1329)
* feat(examples): add wa-sqlite OPFS persistence demo to offline-transactions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(examples): add Vite alias for db-sqlite-persisted-collection-core The browser wa-sqlite package source re-exports from the core package, so resolving from source requires both aliases for the build to succeed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix some type errors * ci: apply automated fixes --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 3380df2 commit e0a59cd

File tree

11 files changed

+491
-10
lines changed

11 files changed

+491
-10
lines changed

examples/react/offline-transactions/package.json

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

examples/react/offline-transactions/src/db/todos.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,16 @@ export const todoAPI = {
118118
}) {
119119
const mutations = transaction.mutations
120120

121-
console.log(`sync todos`, mutations[0].changes, mutations[0].original.text)
121+
console.log(
122+
`sync todos`,
123+
mutations[0].changes,
124+
(mutations[0].original as Record<string, unknown>).text,
125+
)
122126
for (const mutation of mutations) {
123127
try {
124128
switch (mutation.type) {
125129
case `insert`: {
126-
const todoData = mutation.modified as Todo
130+
const todoData = mutation.modified as unknown as Todo
127131
const response = await fetchWithRetry(`/api/todos`, {
128132
method: `POST`,
129133
headers: {
@@ -145,7 +149,7 @@ export const todoAPI = {
145149
case `update`: {
146150
const todoData = mutation.modified as Partial<Todo>
147151
const response = await fetch(
148-
`/api/todos/${(mutation.modified as Todo).id}`,
152+
`/api/todos/${(mutation.modified as unknown as Todo).id}`,
149153
{
150154
method: `PUT`,
151155
headers: {
@@ -167,7 +171,7 @@ export const todoAPI = {
167171

168172
case `delete`: {
169173
const response = await fetchWithRetry(
170-
`/api/todos/${(mutation.original as Todo).id}`,
174+
`/api/todos/${(mutation.original as unknown as Todo).id}`,
171175
{
172176
method: `DELETE`,
173177
headers: {

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>

0 commit comments

Comments
 (0)