Skip to content

Commit 7ba58ee

Browse files
kevin-dpclaudeautofix-ci[bot]
authored
feat(examples): Electron offline-first todo app with SQLite persistence (#1345)
* feat(examples): add Electron offline-first todo app with SQLite persistence Adds a complete Electron example demonstrating offline-first architecture with TanStack DB, combining SQLite persistence for collection data with a SQLite-backed offline transaction outbox. Key features: - SQLite persistence via IPC bridge (main process ↔ renderer) - Custom ElectronSQLiteStorageAdapter for the offline transactions outbox, ensuring pending mutations survive app restarts - Server state persisted to JSON file so data survives dev server restarts - fetchWithRetry for resilient network calls with exponential backoff - Reset button to clear all state (server, SQLite, outbox) - DevTools menu (Cmd+Shift+I) for testing offline mode - StrictMode-safe initialization (ref guard prevents ghost Web Lock from double-mount blocking leader election) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update pnpm-lock.yaml for Electron example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: apply automated fixes * chore: fix sherif issues in Electron example Sort devDependencies alphabetically and align concurrently version with the rest of the workspace. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(examples): wire ElectronCollectionCoordinator for cross-window sync Use ElectronCollectionCoordinator in the Electron todo app so that local mutations are routed through the leader and all windows receive immediate tx:committed notifications, eliminating the flash on insert. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 96cbdb5 commit 7ba58ee

File tree

17 files changed

+2080
-109
lines changed

17 files changed

+2080
-109
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
server/todos.json
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import path from 'node:path'
2+
import { fileURLToPath } from 'node:url'
3+
import { BrowserWindow, Menu, app, ipcMain } from 'electron'
4+
import Database from 'better-sqlite3'
5+
import { createNodeSQLitePersistence } from '@tanstack/db-node-sqlite-persisted-collection'
6+
import { exposeElectronSQLitePersistence } from '@tanstack/db-electron-sqlite-persisted-collection'
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
9+
10+
// Open SQLite database in Electron's user data directory
11+
const dbPath = path.join(app.getPath('userData'), 'todos.sqlite')
12+
console.log(`[Main] SQLite database path: ${dbPath}`)
13+
14+
const database = new Database(dbPath)
15+
16+
// Create persistence adapter from better-sqlite3 database
17+
const persistence = createNodeSQLitePersistence({ database })
18+
19+
// Expose persistence over IPC so the renderer can use it
20+
exposeElectronSQLitePersistence({ ipcMain, persistence })
21+
22+
// ── Key-value store for offline transaction outbox ──
23+
// Uses a simple SQLite table so pending mutations survive app restarts.
24+
database.exec(`
25+
CREATE TABLE IF NOT EXISTS kv_store (
26+
key TEXT PRIMARY KEY,
27+
value TEXT NOT NULL
28+
)
29+
`)
30+
31+
ipcMain.handle('kv:get', (_e, key: string) => {
32+
const row = database
33+
.prepare('SELECT value FROM kv_store WHERE key = ?')
34+
.get(key) as { value: string } | undefined
35+
console.log(
36+
`[KV] get "${key}" → ${row ? `found (${row.value.length} chars)` : 'null'}`,
37+
)
38+
return row?.value ?? null
39+
})
40+
41+
ipcMain.handle('kv:set', (_e, key: string, value: string) => {
42+
console.log(`[KV] set "${key}" (${value.length} chars)`)
43+
database
44+
.prepare(
45+
'INSERT INTO kv_store (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
46+
)
47+
.run(key, value)
48+
})
49+
50+
ipcMain.handle('kv:delete', (_e, key: string) => {
51+
console.log(`[KV] delete "${key}"`)
52+
database.prepare('DELETE FROM kv_store WHERE key = ?').run(key)
53+
})
54+
55+
ipcMain.handle('kv:keys', () => {
56+
const rows = database.prepare('SELECT key FROM kv_store').all() as Array<{
57+
key: string
58+
}>
59+
console.log(`[KV] keys → [${rows.map((r) => `"${r.key}"`).join(', ')}]`)
60+
return rows.map((r) => r.key)
61+
})
62+
63+
ipcMain.handle('kv:clear', () => {
64+
database.exec('DELETE FROM kv_store')
65+
})
66+
67+
// Reset: drop all tables from the SQLite database
68+
ipcMain.handle('tanstack-db:reset-database', () => {
69+
const tables = database
70+
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
71+
.all() as Array<{ name: string }>
72+
for (const { name } of tables) {
73+
database.prepare(`DROP TABLE IF EXISTS "${name}"`).run()
74+
}
75+
console.log('[Main] Database reset — all tables dropped')
76+
})
77+
78+
function createWindow() {
79+
const preloadPath = path.join(__dirname, 'preload.cjs')
80+
81+
const win = new BrowserWindow({
82+
width: 800,
83+
height: 600,
84+
webPreferences: {
85+
contextIsolation: true,
86+
nodeIntegration: false,
87+
preload: preloadPath,
88+
},
89+
})
90+
91+
// Dev: load Vite dev server. Prod: load built files.
92+
if (process.env.NODE_ENV !== 'production') {
93+
win.loadURL('http://localhost:5173')
94+
} else {
95+
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
96+
}
97+
}
98+
99+
app.whenReady().then(() => {
100+
// Add a menu with "New Window" so cross-window sync can be tested.
101+
// BroadcastChannel only works between windows in the same Electron process,
102+
// so opening a second `electron .` process won't sync — use this menu instead.
103+
const menu = Menu.buildFromTemplate([
104+
{
105+
label: app.name,
106+
submenu: [{ role: 'quit' }],
107+
},
108+
{
109+
label: 'File',
110+
submenu: [
111+
{
112+
label: 'New Window',
113+
accelerator: 'CmdOrCtrl+N',
114+
click: () => createWindow(),
115+
},
116+
{ role: 'close' },
117+
],
118+
},
119+
{
120+
label: 'View',
121+
submenu: [
122+
{
123+
label: 'Toggle DevTools',
124+
accelerator: 'CmdOrCtrl+Shift+I',
125+
click: (_item, win) => win?.webContents.toggleDevTools(),
126+
},
127+
{ role: 'reload' },
128+
{ role: 'forceReload' },
129+
],
130+
},
131+
{
132+
label: 'Edit',
133+
submenu: [
134+
{ role: 'undo' },
135+
{ role: 'redo' },
136+
{ type: 'separator' },
137+
{ role: 'cut' },
138+
{ role: 'copy' },
139+
{ role: 'paste' },
140+
{ role: 'selectAll' },
141+
],
142+
},
143+
])
144+
Menu.setApplicationMenu(menu)
145+
146+
createWindow()
147+
})
148+
149+
app.on('window-all-closed', () => {
150+
if (process.platform !== 'darwin') {
151+
app.quit()
152+
}
153+
})
154+
155+
app.on('activate', () => {
156+
if (BrowserWindow.getAllWindows().length === 0) {
157+
createWindow()
158+
}
159+
})
160+
161+
app.on('before-quit', () => {
162+
database.close()
163+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const { contextBridge, ipcRenderer } = require('electron')
2+
3+
contextBridge.exposeInMainWorld('electronAPI', {
4+
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
5+
resetDatabase: () => ipcRenderer.invoke('tanstack-db:reset-database'),
6+
kv: {
7+
get: (key) => ipcRenderer.invoke('kv:get', key),
8+
set: (key, value) => ipcRenderer.invoke('kv:set', key, value),
9+
delete: (key) => ipcRenderer.invoke('kv:delete', key),
10+
keys: () => ipcRenderer.invoke('kv:keys'),
11+
clear: () => ipcRenderer.invoke('kv:clear'),
12+
},
13+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>TanStack DB – Electron Offline-First</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "offline-first-electron",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"main": "electron/main.ts",
7+
"scripts": {
8+
"dev": "concurrently \"pnpm dev:renderer\" \"pnpm dev:server\" \"pnpm dev:electron\"",
9+
"dev:renderer": "vite",
10+
"dev:server": "tsx server/index.ts",
11+
"dev:electron": "wait-on http://localhost:5173 && electron .",
12+
"server": "tsx server/index.ts",
13+
"postinstall": "prebuild-install --runtime electron --target 40.2.1 --arch arm64 || echo 'prebuild-install failed, try: npx @electron/rebuild'"
14+
},
15+
"dependencies": {
16+
"@tanstack/db-electron-sqlite-persisted-collection": "workspace:*",
17+
"@tanstack/db-node-sqlite-persisted-collection": "workspace:*",
18+
"@tanstack/offline-transactions": "workspace:*",
19+
"@tanstack/query-db-collection": "workspace:*",
20+
"@tanstack/react-db": "workspace:*",
21+
"@tanstack/react-query": "^5.90.20",
22+
"better-sqlite3": "^12.6.2",
23+
"react": "^19.2.4",
24+
"react-dom": "^19.2.4",
25+
"zod": "^3.25.76"
26+
},
27+
"devDependencies": {
28+
"@electron/rebuild": "^3.7.1",
29+
"@types/better-sqlite3": "^7.6.13",
30+
"@types/cors": "^2.8.19",
31+
"@types/express": "^5.0.6",
32+
"@types/react": "^19.2.13",
33+
"@types/react-dom": "^19.2.3",
34+
"@vitejs/plugin-react": "^5.1.3",
35+
"concurrently": "^9.2.1",
36+
"cors": "^2.8.6",
37+
"electron": "^40.2.1",
38+
"express": "^5.2.1",
39+
"tsx": "^4.21.0",
40+
"typescript": "^5.9.2",
41+
"vite": "^7.3.0",
42+
"wait-on": "^8.0.3"
43+
}
44+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import cors from 'cors'
5+
import express from 'express'
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
8+
9+
const app = express()
10+
const PORT = 3001
11+
12+
app.use(cors())
13+
app.use(express.json())
14+
15+
// Types
16+
interface Todo {
17+
id: string
18+
text: string
19+
completed: boolean
20+
createdAt: string
21+
updatedAt: string
22+
}
23+
24+
// Persist server state to a JSON file so data survives restarts
25+
const TODOS_FILE = path.join(__dirname, 'todos.json')
26+
27+
function loadTodos(): Map<string, Todo> {
28+
try {
29+
const data = JSON.parse(fs.readFileSync(TODOS_FILE, 'utf-8')) as Array<Todo>
30+
return new Map(data.map((t) => [t.id, t]))
31+
} catch {
32+
return new Map()
33+
}
34+
}
35+
36+
function saveTodos() {
37+
fs.writeFileSync(
38+
TODOS_FILE,
39+
JSON.stringify(Array.from(todosStore.values()), null, 2),
40+
)
41+
}
42+
43+
const todosStore = loadTodos()
44+
45+
// Helper function to generate IDs
46+
function generateId(): string {
47+
return Math.random().toString(36).substring(2) + Date.now().toString(36)
48+
}
49+
50+
// Simulate network delay
51+
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
52+
53+
// GET all todos
54+
app.get('/api/todos', async (_req, res) => {
55+
console.log('GET /api/todos')
56+
await delay(200)
57+
const todos = Array.from(todosStore.values()).sort(
58+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
59+
)
60+
res.json(todos)
61+
})
62+
63+
// POST create todo — accepts client-generated ID
64+
app.post('/api/todos', async (req, res) => {
65+
console.log('POST /api/todos', req.body)
66+
await delay(200)
67+
68+
const { id, text, completed } = req.body
69+
if (!text || text.trim() === '') {
70+
return res.status(400).json({ error: 'Todo text is required' })
71+
}
72+
73+
const now = new Date().toISOString()
74+
const todo: Todo = {
75+
id: id || generateId(),
76+
text,
77+
completed: completed ?? false,
78+
createdAt: now,
79+
updatedAt: now,
80+
}
81+
todosStore.set(todo.id, todo)
82+
saveTodos()
83+
res.status(201).json(todo)
84+
})
85+
86+
// PUT update todo
87+
app.put('/api/todos/:id', async (req, res) => {
88+
console.log('PUT /api/todos/' + req.params.id, req.body)
89+
await delay(200)
90+
91+
const existing = todosStore.get(req.params.id)
92+
if (!existing) {
93+
return res.status(404).json({ error: 'Todo not found' })
94+
}
95+
96+
const updated: Todo = {
97+
...existing,
98+
...req.body,
99+
updatedAt: new Date().toISOString(),
100+
}
101+
todosStore.set(req.params.id, updated)
102+
saveTodos()
103+
res.json(updated)
104+
})
105+
106+
// DELETE todo
107+
app.delete('/api/todos/:id', async (req, res) => {
108+
console.log('DELETE /api/todos/' + req.params.id)
109+
await delay(200)
110+
111+
if (!todosStore.delete(req.params.id)) {
112+
return res.status(404).json({ error: 'Todo not found' })
113+
}
114+
saveTodos()
115+
res.json({ success: true })
116+
})
117+
118+
// DELETE all todos
119+
app.delete('/api/todos', async (_req, res) => {
120+
console.log('DELETE /api/todos (clear all)')
121+
await delay(200)
122+
todosStore.clear()
123+
saveTodos()
124+
res.json({ success: true })
125+
})
126+
127+
app.listen(PORT, '0.0.0.0', () => {
128+
console.log(`Server running at http://0.0.0.0:${PORT}`)
129+
})

0 commit comments

Comments
 (0)