From d7a150d5cbcb41f349106cfd5de70362fb4b59e5 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 4 Mar 2026 11:46:03 +0100 Subject: [PATCH 1/3] 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 --- .../react/offline-transactions/package.json | 3 + .../src/components/PersistedTodoDemo.tsx | 183 ++++++++++++++++++ .../src/db/persisted-todos.ts | 53 +++++ .../offline-transactions/src/routeTree.gen.ts | 24 ++- .../react/offline-transactions/src/router.tsx | 4 + .../src/routes/__root.tsx | 9 + .../offline-transactions/src/routes/index.tsx | 37 ++++ .../src/routes/wa-sqlite.tsx | 94 +++++++++ .../react/offline-transactions/vite.config.ts | 73 ++++++- pnpm-lock.yaml | 9 + 10 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx create mode 100644 examples/react/offline-transactions/src/db/persisted-todos.ts create mode 100644 examples/react/offline-transactions/src/routes/wa-sqlite.tsx diff --git a/examples/react/offline-transactions/package.json b/examples/react/offline-transactions/package.json index badb36576..5bfa7b741 100644 --- a/examples/react/offline-transactions/package.json +++ b/examples/react/offline-transactions/package.json @@ -9,6 +9,9 @@ "start": "node .output/server/index.mjs" }, "dependencies": { + "@journeyapps/wa-sqlite": "^1.4.1", + "@tanstack/db": "workspace:*", + "@tanstack/db-browser-wa-sqlite-persisted-collection": "workspace:*", "@tanstack/offline-transactions": "^1.0.21", "@tanstack/query-db-collection": "^1.0.27", "@tanstack/react-db": "^0.1.74", diff --git a/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx b/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx new file mode 100644 index 000000000..efb38cc80 --- /dev/null +++ b/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx @@ -0,0 +1,183 @@ +import React, { useState } from 'react' +import { useLiveQuery } from '@tanstack/react-db' +import type { Collection } from '@tanstack/db' +import type { PersistedTodo } from '~/db/persisted-todos' + +interface PersistedTodoDemoProps { + collection: Collection +} + +export function PersistedTodoDemo({ collection }: PersistedTodoDemoProps) { + const [newTodoText, setNewTodoText] = useState(``) + const [error, setError] = useState(null) + + const { data: todoList = [] } = useLiveQuery((q) => + q + .from({ todo: collection }) + .orderBy(({ todo }) => todo.createdAt, `desc`), + ) + + const handleAddTodo = () => { + if (!newTodoText.trim()) return + + try { + setError(null) + const now = new Date().toISOString() + collection.insert({ + id: crypto.randomUUID(), + text: newTodoText.trim(), + completed: false, + createdAt: now, + updatedAt: now, + }) + setNewTodoText(``) + } catch (err) { + setError(err instanceof Error ? err.message : `Failed to add todo`) + } + } + + const handleToggleTodo = (id: string) => { + try { + setError(null) + collection.update(id, (draft) => { + draft.completed = !draft.completed + draft.updatedAt = new Date().toISOString() + }) + } catch (err) { + setError(err instanceof Error ? err.message : `Failed to toggle todo`) + } + } + + const handleDeleteTodo = (id: string) => { + try { + setError(null) + collection.delete(id) + } catch (err) { + setError(err instanceof Error ? err.message : `Failed to delete todo`) + } + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === `Enter`) { + handleAddTodo() + } + } + + return ( +
+
+
+ 🗃️ +
+

+ wa-sqlite OPFS Persistence Demo +

+

+ Collection data is persisted to SQLite via OPFS. Data survives + page reloads without any server sync. +

+
+
+ + {/* Persistence indicator */} +
+
+
+ SQLite OPFS Persistence Active +
+
+ {todoList.length} todo{todoList.length !== 1 ? `s` : ``} +
+
+ + {/* Error display */} + {error && ( +
+

{error}

+
+ )} + + {/* Add new todo */} +
+ setNewTodoText(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Add a new todo..." + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ + {/* Todo list */} +
+ {todoList.length === 0 ? ( +
+ No todos yet. Add one above to get started! +
+ + Try adding todos, then refresh the page to see them persist + +
+ ) : ( + todoList.map((todo) => ( +
+ + + {todo.text} + + + {new Date(todo.createdAt).toLocaleDateString()} + + +
+ )) + )} +
+ + {/* Instructions */} +
+

Try this:

+
    +
  1. 1. Add some todos
  2. +
  3. 2. Refresh the page (Ctrl+R / Cmd+R)
  4. +
  5. 3. Your todos are still here - persisted in SQLite via OPFS!
  6. +
  7. + 4. This uses wa-sqlite with OPFSCoopSyncVFS in a Web Worker +
  8. +
+
+
+
+ ) +} diff --git a/examples/react/offline-transactions/src/db/persisted-todos.ts b/examples/react/offline-transactions/src/db/persisted-todos.ts new file mode 100644 index 000000000..fd3aea48a --- /dev/null +++ b/examples/react/offline-transactions/src/db/persisted-todos.ts @@ -0,0 +1,53 @@ +import { createCollection } from '@tanstack/react-db' +import { + BrowserCollectionCoordinator, + createBrowserWASQLitePersistence, + openBrowserWASQLiteOPFSDatabase, + persistedCollectionOptions, +} from '@tanstack/db-browser-wa-sqlite-persisted-collection' +import type { Collection } from '@tanstack/db' + +export type PersistedTodo = { + id: string + text: string + completed: boolean + createdAt: string + updatedAt: string +} + +export type PersistedTodosHandle = { + collection: Collection + close: () => Promise +} + +export async function createPersistedTodoCollection(): Promise { + const database = await openBrowserWASQLiteOPFSDatabase({ + databaseName: `tanstack-db-demo-v2.sqlite`, + }) + + const coordinator = new BrowserCollectionCoordinator({ + dbName: `tanstack-db-demo`, + }) + + const persistence = createBrowserWASQLitePersistence({ + database, + coordinator, + }) + + const collection = createCollection( + persistedCollectionOptions({ + id: `persisted-todos`, + getKey: (todo) => todo.id, + persistence, + schemaVersion: 1, + }), + ) + + return { + collection, + close: async () => { + coordinator.dispose() + await database.close?.() + }, + } +} diff --git a/examples/react/offline-transactions/src/routeTree.gen.ts b/examples/react/offline-transactions/src/routeTree.gen.ts index d594674fe..c59e9b6ff 100644 --- a/examples/react/offline-transactions/src/routeTree.gen.ts +++ b/examples/react/offline-transactions/src/routeTree.gen.ts @@ -9,10 +9,16 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WaSqliteRouteImport } from './routes/wa-sqlite' import { Route as LocalstorageRouteImport } from './routes/localstorage' import { Route as IndexeddbRouteImport } from './routes/indexeddb' import { Route as IndexRouteImport } from './routes/index' +const WaSqliteRoute = WaSqliteRouteImport.update({ + id: '/wa-sqlite', + path: '/wa-sqlite', + getParentRoute: () => rootRouteImport, +} as any) const LocalstorageRoute = LocalstorageRouteImport.update({ id: '/localstorage', path: '/localstorage', @@ -33,34 +39,45 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/indexeddb': typeof IndexeddbRoute '/localstorage': typeof LocalstorageRoute + '/wa-sqlite': typeof WaSqliteRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/indexeddb': typeof IndexeddbRoute '/localstorage': typeof LocalstorageRoute + '/wa-sqlite': typeof WaSqliteRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/indexeddb': typeof IndexeddbRoute '/localstorage': typeof LocalstorageRoute + '/wa-sqlite': typeof WaSqliteRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/indexeddb' | '/localstorage' + fullPaths: '/' | '/indexeddb' | '/localstorage' | '/wa-sqlite' fileRoutesByTo: FileRoutesByTo - to: '/' | '/indexeddb' | '/localstorage' - id: '__root__' | '/' | '/indexeddb' | '/localstorage' + to: '/' | '/indexeddb' | '/localstorage' | '/wa-sqlite' + id: '__root__' | '/' | '/indexeddb' | '/localstorage' | '/wa-sqlite' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute IndexeddbRoute: typeof IndexeddbRoute LocalstorageRoute: typeof LocalstorageRoute + WaSqliteRoute: typeof WaSqliteRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/wa-sqlite': { + id: '/wa-sqlite' + path: '/wa-sqlite' + fullPath: '/wa-sqlite' + preLoaderRoute: typeof WaSqliteRouteImport + parentRoute: typeof rootRouteImport + } '/localstorage': { id: '/localstorage' path: '/localstorage' @@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, IndexeddbRoute: IndexeddbRoute, LocalstorageRoute: LocalstorageRoute, + WaSqliteRoute: WaSqliteRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/examples/react/offline-transactions/src/router.tsx b/examples/react/offline-transactions/src/router.tsx index d5579a802..e9f12d870 100644 --- a/examples/react/offline-transactions/src/router.tsx +++ b/examples/react/offline-transactions/src/router.tsx @@ -15,6 +15,10 @@ export function createRouter() { return router } +export async function getRouter() { + return createRouter() +} + declare module '@tanstack/react-router' { interface Register { router: ReturnType diff --git a/examples/react/offline-transactions/src/routes/__root.tsx b/examples/react/offline-transactions/src/routes/__root.tsx index 89588bacb..fb72e0806 100644 --- a/examples/react/offline-transactions/src/routes/__root.tsx +++ b/examples/react/offline-transactions/src/routes/__root.tsx @@ -109,6 +109,15 @@ function RootDocument({ children }: { children: React.ReactNode }) { > 💾 localStorage + + 🗃️ wa-sqlite +
diff --git a/examples/react/offline-transactions/src/routes/index.tsx b/examples/react/offline-transactions/src/routes/index.tsx index 6e9141e5d..bb98d7561 100644 --- a/examples/react/offline-transactions/src/routes/index.tsx +++ b/examples/react/offline-transactions/src/routes/index.tsx @@ -73,6 +73,43 @@ function Home() { +
+ +
+
+ 🗃️ +
+

+ wa-sqlite OPFS Persistence +

+ + NEW + +
+
+

+ Collection-level persistence using wa-sqlite with OPFS. Data is + stored in a real SQLite database in the browser via a Web + Worker. Survives page reloads without server sync. +

+
+ + SQLite in Browser + + + OPFS Storage + + + Web Worker + + + Local-only + +
+
+ +
+

Features Demonstrated diff --git a/examples/react/offline-transactions/src/routes/wa-sqlite.tsx b/examples/react/offline-transactions/src/routes/wa-sqlite.tsx new file mode 100644 index 000000000..92882a6e3 --- /dev/null +++ b/examples/react/offline-transactions/src/routes/wa-sqlite.tsx @@ -0,0 +1,94 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import type {PersistedTodosHandle} from '~/db/persisted-todos'; +import { PersistedTodoDemo } from '~/components/PersistedTodoDemo' +import { + + createPersistedTodoCollection +} from '~/db/persisted-todos' + +export const Route = createFileRoute(`/wa-sqlite`)({ + component: WASQLiteDemo, +}) + +function WASQLiteDemo() { + const [handle, setHandle] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let disposed = false + let currentHandle: PersistedTodosHandle | null = null + + createPersistedTodoCollection() + .then((h) => { + if (disposed) { + h.close() + return + } + currentHandle = h + setHandle(h) + }) + .catch((err) => { + if (!disposed) { + console.error(`Failed to initialize wa-sqlite persistence:`, err) + setError( + err instanceof Error ? err.message : `Failed to initialize persistence`, + ) + } + }) + + return () => { + disposed = true + currentHandle?.close() + } + }, []) + + if (error) { + return ( +
+
+
+
+ ⚠️ +
+

+ Persistence Unavailable +

+

+ wa-sqlite OPFS persistence could not be initialized. +

+
+
+
+

{error}

+
+

+ This feature requires a browser with OPFS support (Chrome 102+, + Edge 102+, Firefox 111+, Safari 15.2+) and a secure context + (HTTPS or localhost). +

+
+
+
+ ) + } + + if (!handle) { + return ( +
+
+
+
+

Initializing wa-sqlite persistence...

+
+
+
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/examples/react/offline-transactions/vite.config.ts b/examples/react/offline-transactions/vite.config.ts index 402e2d611..c2319dc5f 100644 --- a/examples/react/offline-transactions/vite.config.ts +++ b/examples/react/offline-transactions/vite.config.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs' import path from 'node:path' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { defineConfig } from 'vite' @@ -13,6 +14,14 @@ function watchWorkspacePackages() { const watchPaths = [ path.resolve(__dirname, `../../../packages/db/dist`), path.resolve(__dirname, `../../../packages/offline-transactions/dist`), + path.resolve( + __dirname, + `../../../packages/db-browser-wa-sqlite-persisted-collection/src`, + ), + path.resolve( + __dirname, + `../../../packages/db-sqlite-persisted-collection-core/dist`, + ), ] console.log(`[watch-workspace] Starting to watch paths:`) @@ -21,20 +30,22 @@ function watchWorkspacePackages() { console.log(`[watch-workspace] Resolved paths:`) watchPaths.forEach((p) => console.log(` - ${path.resolve(p)}`)) + let ready = false + const watcher = chokidar.watch(watchPaths, { ignored: /node_modules/, persistent: true, }) watcher.on(`ready`, () => { + ready = true console.log( `[watch-workspace] Initial scan complete. Watching for changes...`, ) - const watchedPaths = watcher.getWatched() - console.log(`[watch-workspace] Currently watching:`, watchedPaths) }) watcher.on(`add`, (filePath) => { + if (!ready) return console.log(`[watch-workspace] File added: ${filePath}`) server.ws.send({ type: `full-reload`, @@ -42,6 +53,7 @@ function watchWorkspacePackages() { }) watcher.on(`change`, (filePath) => { + if (!ready) return console.log(`[watch-workspace] File changed: ${filePath}`) server.ws.send({ type: `full-reload`, @@ -62,10 +74,65 @@ export default defineConfig({ ignored: [`!**/node_modules/@tanstack/**`], }, }, + resolve: { + alias: { + // Resolve to source so Vite can process the ?worker import natively + '@tanstack/db-browser-wa-sqlite-persisted-collection': path.resolve( + __dirname, + `../../../packages/db-browser-wa-sqlite-persisted-collection/src/index.ts`, + ), + }, + }, optimizeDeps: { - exclude: [`@tanstack/db`, `@tanstack/offline-transactions`], + exclude: [ + `@tanstack/db`, + `@tanstack/offline-transactions`, + `@tanstack/db-browser-wa-sqlite-persisted-collection`, + `@tanstack/db-sqlite-persisted-collection-core`, + `@journeyapps/wa-sqlite`, + ], }, plugins: [ + // Serve .wasm files before TanStack Start's catch-all handler intercepts them. + // We use configureServer returning a function (post-hook) and unshift onto the + // stack so this runs before any other middleware including TanStack Start. + { + name: `serve-wasm-files`, + configureServer(server: any) { + const wasmHandler = (req: any, res: any, next: () => void) => { + // Strip query string before checking extension + const urlWithoutQuery = (req.url ?? ``).split(`?`)[0] + if (!urlWithoutQuery.endsWith(`.wasm`)) { + return next() + } + + // Handle /@fs/ paths used by Vite for serving node_modules files + const fsPrefix = `/@fs` + let filePath: string | undefined + if (urlWithoutQuery.startsWith(fsPrefix)) { + filePath = urlWithoutQuery.slice(fsPrefix.length) + } + + if (!filePath || !fs.existsSync(filePath)) { + return next() + } + + const content = fs.readFileSync(filePath) + res.writeHead(200, { + 'Content-Type': `application/wasm`, + 'Content-Length': content.byteLength, + 'Cache-Control': `no-cache`, + }) + res.end(content) + } + + // Prepend to the middleware stack so it runs before TanStack Start + server.middlewares.stack.unshift({ + route: ``, + handle: wasmHandler, + }) + }, + }, watchWorkspacePackages(), tsConfigPaths({ projects: [`./tsconfig.json`], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 666bb0866..182fd851d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,15 @@ importers: examples/react/offline-transactions: dependencies: + '@journeyapps/wa-sqlite': + specifier: ^1.4.1 + version: 1.4.1 + '@tanstack/db': + specifier: workspace:* + version: link:../../../packages/db + '@tanstack/db-browser-wa-sqlite-persisted-collection': + specifier: workspace:* + version: link:../../../packages/db-browser-wa-sqlite-persisted-collection '@tanstack/offline-transactions': specifier: ^1.0.21 version: link:../../../packages/offline-transactions From 185c965a62f6c68ebc789ec85399928d48f4126e Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 5 Mar 2026 14:13:32 +0100 Subject: [PATCH 2/3] fix: update pnpm-lock.yaml for wa-sqlite dependency resolution Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 182fd851d..c87864614 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,7 +279,7 @@ importers: dependencies: '@journeyapps/wa-sqlite': specifier: ^1.4.1 - version: 1.4.1 + version: 1.5.0 '@tanstack/db': specifier: workspace:* version: link:../../../packages/db From dc172819e4a04ae4a577d0b438766666c8d20484 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:14:34 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- .../src/components/PersistedTodoDemo.tsx | 8 +++----- .../src/routes/wa-sqlite.tsx | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx b/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx index efb38cc80..e7252f5f4 100644 --- a/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx +++ b/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx @@ -12,9 +12,7 @@ export function PersistedTodoDemo({ collection }: PersistedTodoDemoProps) { const [error, setError] = useState(null) const { data: todoList = [] } = useLiveQuery((q) => - q - .from({ todo: collection }) - .orderBy(({ todo }) => todo.createdAt, `desc`), + q.from({ todo: collection }).orderBy(({ todo }) => todo.createdAt, `desc`), ) const handleAddTodo = () => { @@ -171,10 +169,10 @@ export function PersistedTodoDemo({ collection }: PersistedTodoDemoProps) {
  1. 1. Add some todos
  2. 2. Refresh the page (Ctrl+R / Cmd+R)
  3. -
  4. 3. Your todos are still here - persisted in SQLite via OPFS!
  5. - 4. This uses wa-sqlite with OPFSCoopSyncVFS in a Web Worker + 3. Your todos are still here - persisted in SQLite via OPFS!
  6. +
  7. 4. This uses wa-sqlite with OPFSCoopSyncVFS in a Web Worker

diff --git a/examples/react/offline-transactions/src/routes/wa-sqlite.tsx b/examples/react/offline-transactions/src/routes/wa-sqlite.tsx index 92882a6e3..a6a63de38 100644 --- a/examples/react/offline-transactions/src/routes/wa-sqlite.tsx +++ b/examples/react/offline-transactions/src/routes/wa-sqlite.tsx @@ -1,11 +1,8 @@ import { createFileRoute } from '@tanstack/react-router' import { useEffect, useState } from 'react' -import type {PersistedTodosHandle} from '~/db/persisted-todos'; +import type { PersistedTodosHandle } from '~/db/persisted-todos' import { PersistedTodoDemo } from '~/components/PersistedTodoDemo' -import { - - createPersistedTodoCollection -} from '~/db/persisted-todos' +import { createPersistedTodoCollection } from '~/db/persisted-todos' export const Route = createFileRoute(`/wa-sqlite`)({ component: WASQLiteDemo, @@ -32,7 +29,9 @@ function WASQLiteDemo() { if (!disposed) { console.error(`Failed to initialize wa-sqlite persistence:`, err) setError( - err instanceof Error ? err.message : `Failed to initialize persistence`, + err instanceof Error + ? err.message + : `Failed to initialize persistence`, ) } }) @@ -64,8 +63,8 @@ function WASQLiteDemo() {

This feature requires a browser with OPFS support (Chrome 102+, - Edge 102+, Firefox 111+, Safari 15.2+) and a secure context - (HTTPS or localhost). + Edge 102+, Firefox 111+, Safari 15.2+) and a secure context (HTTPS + or localhost).

@@ -79,7 +78,9 @@ function WASQLiteDemo() {
-

Initializing wa-sqlite persistence...

+

+ Initializing wa-sqlite persistence... +