Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/electron/offline-first/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
server/todos.json
163 changes: 163 additions & 0 deletions examples/electron/offline-first/electron/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { BrowserWindow, Menu, app, ipcMain } from 'electron'
import Database from 'better-sqlite3'
import { createNodeSQLitePersistence } from '@tanstack/db-node-sqlite-persisted-collection'
import { exposeElectronSQLitePersistence } from '@tanstack/db-electron-sqlite-persisted-collection'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

// Open SQLite database in Electron's user data directory
const dbPath = path.join(app.getPath('userData'), 'todos.sqlite')
console.log(`[Main] SQLite database path: ${dbPath}`)

const database = new Database(dbPath)

// Create persistence adapter from better-sqlite3 database
const persistence = createNodeSQLitePersistence({ database })

// Expose persistence over IPC so the renderer can use it
exposeElectronSQLitePersistence({ ipcMain, persistence })

// ── Key-value store for offline transaction outbox ──
// Uses a simple SQLite table so pending mutations survive app restarts.
database.exec(`
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`)

ipcMain.handle('kv:get', (_e, key: string) => {
const row = database
.prepare('SELECT value FROM kv_store WHERE key = ?')
.get(key) as { value: string } | undefined
console.log(
`[KV] get "${key}" → ${row ? `found (${row.value.length} chars)` : 'null'}`,
)
return row?.value ?? null
})

ipcMain.handle('kv:set', (_e, key: string, value: string) => {
console.log(`[KV] set "${key}" (${value.length} chars)`)
database
.prepare(
'INSERT INTO kv_store (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
)
.run(key, value)
})

ipcMain.handle('kv:delete', (_e, key: string) => {
console.log(`[KV] delete "${key}"`)
database.prepare('DELETE FROM kv_store WHERE key = ?').run(key)
})

ipcMain.handle('kv:keys', () => {
const rows = database.prepare('SELECT key FROM kv_store').all() as Array<{
key: string
}>
console.log(`[KV] keys → [${rows.map((r) => `"${r.key}"`).join(', ')}]`)
return rows.map((r) => r.key)
})

ipcMain.handle('kv:clear', () => {
database.exec('DELETE FROM kv_store')
})

// Reset: drop all tables from the SQLite database
ipcMain.handle('tanstack-db:reset-database', () => {
const tables = database
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as Array<{ name: string }>
for (const { name } of tables) {
database.prepare(`DROP TABLE IF EXISTS "${name}"`).run()
}
console.log('[Main] Database reset — all tables dropped')
})

function createWindow() {
const preloadPath = path.join(__dirname, 'preload.cjs')

const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: preloadPath,
},
})

// Dev: load Vite dev server. Prod: load built files.
if (process.env.NODE_ENV !== 'production') {
win.loadURL('http://localhost:5173')
} else {
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
}
}

app.whenReady().then(() => {
// Add a menu with "New Window" so cross-window sync can be tested.
// BroadcastChannel only works between windows in the same Electron process,
// so opening a second `electron .` process won't sync — use this menu instead.
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [{ role: 'quit' }],
},
{
label: 'File',
submenu: [
{
label: 'New Window',
accelerator: 'CmdOrCtrl+N',
click: () => createWindow(),
},
{ role: 'close' },
],
},
{
label: 'View',
submenu: [
{
label: 'Toggle DevTools',
accelerator: 'CmdOrCtrl+Shift+I',
click: (_item, win) => win?.webContents.toggleDevTools(),
},
{ role: 'reload' },
{ role: 'forceReload' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
])
Menu.setApplicationMenu(menu)

createWindow()
})

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})

app.on('before-quit', () => {
database.close()
})
13 changes: 13 additions & 0 deletions examples/electron/offline-first/electron/preload.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
resetDatabase: () => ipcRenderer.invoke('tanstack-db:reset-database'),
kv: {
get: (key) => ipcRenderer.invoke('kv:get', key),
set: (key, value) => ipcRenderer.invoke('kv:set', key, value),
delete: (key) => ipcRenderer.invoke('kv:delete', key),
keys: () => ipcRenderer.invoke('kv:keys'),
clear: () => ipcRenderer.invoke('kv:clear'),
},
})
12 changes: 12 additions & 0 deletions examples/electron/offline-first/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TanStack DB – Electron Offline-First</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
44 changes: 44 additions & 0 deletions examples/electron/offline-first/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "offline-first-electron",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "electron/main.ts",
"scripts": {
"dev": "concurrently \"pnpm dev:renderer\" \"pnpm dev:server\" \"pnpm dev:electron\"",
"dev:renderer": "vite",
"dev:server": "tsx server/index.ts",
"dev:electron": "wait-on http://localhost:5173 && electron .",
"server": "tsx server/index.ts",
"postinstall": "prebuild-install --runtime electron --target 40.2.1 --arch arm64 || echo 'prebuild-install failed, try: npx @electron/rebuild'"
},
"dependencies": {
"@tanstack/db-electron-sqlite-persisted-collection": "workspace:*",
"@tanstack/db-node-sqlite-persisted-collection": "workspace:*",
"@tanstack/offline-transactions": "workspace:*",
"@tanstack/query-db-collection": "workspace:*",
"@tanstack/react-db": "workspace:*",
"@tanstack/react-query": "^5.90.20",
"better-sqlite3": "^12.6.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^3.25.76"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.3",
"concurrently": "^9.2.1",
"cors": "^2.8.6",
"electron": "^40.2.1",
"express": "^5.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
"vite": "^7.3.0",
"wait-on": "^8.0.3"
}
}
129 changes: 129 additions & 0 deletions examples/electron/offline-first/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import cors from 'cors'
import express from 'express'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const app = express()
const PORT = 3001

app.use(cors())
app.use(express.json())

// Types
interface Todo {
id: string
text: string
completed: boolean
createdAt: string
updatedAt: string
}

// Persist server state to a JSON file so data survives restarts
const TODOS_FILE = path.join(__dirname, 'todos.json')

function loadTodos(): Map<string, Todo> {
try {
const data = JSON.parse(fs.readFileSync(TODOS_FILE, 'utf-8')) as Array<Todo>
return new Map(data.map((t) => [t.id, t]))
} catch {
return new Map()
}
}

function saveTodos() {
fs.writeFileSync(
TODOS_FILE,
JSON.stringify(Array.from(todosStore.values()), null, 2),
)
}

const todosStore = loadTodos()

// Helper function to generate IDs
function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36)
}

// Simulate network delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

// GET all todos
app.get('/api/todos', async (_req, res) => {
console.log('GET /api/todos')
await delay(200)
const todos = Array.from(todosStore.values()).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
res.json(todos)
})

// POST create todo — accepts client-generated ID
app.post('/api/todos', async (req, res) => {
console.log('POST /api/todos', req.body)
await delay(200)

const { id, text, completed } = req.body
if (!text || text.trim() === '') {
return res.status(400).json({ error: 'Todo text is required' })
}

const now = new Date().toISOString()
const todo: Todo = {
id: id || generateId(),
text,
completed: completed ?? false,
createdAt: now,
updatedAt: now,
}
todosStore.set(todo.id, todo)
saveTodos()
res.status(201).json(todo)
})

// PUT update todo
app.put('/api/todos/:id', async (req, res) => {
console.log('PUT /api/todos/' + req.params.id, req.body)
await delay(200)

const existing = todosStore.get(req.params.id)
if (!existing) {
return res.status(404).json({ error: 'Todo not found' })
}

const updated: Todo = {
...existing,
...req.body,
updatedAt: new Date().toISOString(),
}
todosStore.set(req.params.id, updated)
saveTodos()
res.json(updated)
})

// DELETE todo
app.delete('/api/todos/:id', async (req, res) => {
console.log('DELETE /api/todos/' + req.params.id)
await delay(200)

if (!todosStore.delete(req.params.id)) {
return res.status(404).json({ error: 'Todo not found' })
}
saveTodos()
res.json({ success: true })
})

// DELETE all todos
app.delete('/api/todos', async (_req, res) => {
console.log('DELETE /api/todos (clear all)')
await delay(200)
todosStore.clear()
saveTodos()
res.json({ success: true })
})

app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running at http://0.0.0.0:${PORT}`)
})
Loading
Loading