Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
12380f8
feat: add electron sqlite persisted collection package
kevin-dp Mar 12, 2026
e8c09da
feat: add node sqlite persisted collection package
kevin-dp Mar 12, 2026
b3980a6
feat(examples): add op-sqlite persistence to react-native offline-tra…
kevin-dp Mar 5, 2026
7d0bcc5
Modify RN offline TX demo to also use local SQLite persistence
kevin-dp Mar 9, 2026
1b34620
ci: apply automated fixes
autofix-ci[bot] Mar 11, 2026
ebdc861
fix(react-native): align react version with monorepo (^19.2.4)
kevin-dp Mar 12, 2026
e144d1a
fix(react-native): revert react to 19.0.0 and exclude from sherif
kevin-dp Mar 12, 2026
4e897c0
feat: add Intent agent skills for TanStack DB (#1330)
KyleAMathews Mar 6, 2026
2f9430e
update @electric-sql/client version (#1337)
KyleAMathews Mar 6, 2026
1ecc2eb
ci: Version Packages (#1320)
github-actions[bot] Mar 6, 2026
ea91d07
ci: Version Packages (#1338)
github-actions[bot] Mar 6, 2026
07e7bee
fix: resolve all eslint warnings in packages/db (#1340)
KyleAMathews Mar 6, 2026
4e5a888
Fine-grained Solid stores for useLiveQuery (#1316)
kevin-dp Mar 9, 2026
ecf03d0
fix(db): throw error when fn.select() is used with groupBy() (#1324)
kevin-dp Mar 9, 2026
edc16ab
Query once API implementation (#1211)
samwillis Mar 10, 2026
1179b42
fix(db): use Ref<T, Nullable> for left join select refs instead of Re…
kevin-dp Mar 10, 2026
a4c0d3f
Fix unbounded expression growth in DeduplicatedLoadSubset (#1348)
KyleAMathews Mar 10, 2026
b8d8f44
chore: fix all eslint errors and warnings (#1349)
KyleAMathews Mar 10, 2026
766993a
ci: Version Packages (#1344)
github-actions[bot] Mar 10, 2026
6798af5
fix: increase electron persisted collection E2E timeout for full mode
kevin-dp Mar 12, 2026
2802f38
Fix type
kevin-dp Mar 16, 2026
9d45d48
Fix subset dedupe due to mistake in rebae
kevin-dp Mar 16, 2026
ef13ca7
Fix virtual props in tests
kevin-dp Mar 16, 2026
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
10 changes: 10 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ jobs:
env:
ELECTRIC_URL: http://localhost:3000

- name: Run Node SQLite persisted collection E2E tests
run: |
cd packages/db-node-sqlite-persisted-collection
pnpm test:e2e

- name: Run Electron SQLite persisted collection E2E tests (full bridge)
run: |
cd packages/db-electron-sqlite-persisted-collection
TANSTACK_DB_ELECTRON_E2E_ALL=1 pnpm test:e2e

- name: Run React Native/Expo persisted collection E2E tests
run: |
cd packages/db-react-native-sqlite-persisted-collection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ android {
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion

namespace "com.offlinetransactionsdemo"
namespace "com.tanstack.offlinetransactions"
defaultConfig {
applicationId "com.offlinetransactionsdemo"
applicationId "com.tanstack.offlinetransactions"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
Expand Down
22 changes: 9 additions & 13 deletions examples/react-native/offline-transactions/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,21 @@
import '../src/polyfills'

import { Stack } from 'expo-router'
import { QueryClientProvider } from '@tanstack/react-query'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { StatusBar } from 'expo-status-bar'
import { queryClient } from '../src/utils/queryClient'

export default function RootLayout() {
return (
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<StatusBar style="auto" />
<Stack>
<Stack.Screen
name="index"
options={{
title: `Offline Transactions`,
}}
/>
</Stack>
</QueryClientProvider>
<StatusBar style="auto" />
<Stack>
<Stack.Screen
name="index"
options={{
title: `Offline Transactions + SQLite`,
}}
/>
</Stack>
</SafeAreaProvider>
)
}
94 changes: 93 additions & 1 deletion examples/react-native/offline-transactions/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,102 @@
import React, { useEffect, useState } from 'react'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { TodoList } from '../src/components/TodoList'
import { createTodos } from '../src/db/todos'
import type { TodosHandle } from '../src/db/todos'

export default function HomeScreen() {
const [handle, setHandle] = useState<TodosHandle | null>(null)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
let disposed = false
let currentHandle: TodosHandle | null = null

try {
const h = createTodos()
if (disposed as boolean) {
h.close()
return
}
currentHandle = h
setHandle(h)
} catch (err) {
if (!(disposed as boolean)) {
console.error(`Failed to initialize:`, err)
setError(err instanceof Error ? err.message : `Failed to initialize`)
}
}

return () => {
disposed = true
currentHandle?.close()
}
}, [])

if (error) {
return (
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}>Initialization Error</Text>
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text>
</View>
</View>
</SafeAreaView>
)
}

if (!handle) {
return (
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={styles.loadingText}>Initializing...</Text>
</View>
</SafeAreaView>
)
}

return (
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
<TodoList />
<TodoList collection={handle.collection} executor={handle.executor} />
</SafeAreaView>
)
}

const styles = StyleSheet.create({
errorContainer: {
flex: 1,
padding: 16,
backgroundColor: `#f5f5f5`,
},
errorTitle: {
fontSize: 24,
fontWeight: `bold`,
color: `#111`,
marginBottom: 16,
},
errorBox: {
backgroundColor: `#fee2e2`,
borderWidth: 1,
borderColor: `#fca5a5`,
borderRadius: 8,
padding: 12,
},
errorText: {
color: `#dc2626`,
fontSize: 14,
},
loadingContainer: {
flex: 1,
justifyContent: `center`,
alignItems: `center`,
gap: 12,
backgroundColor: `#f5f5f5`,
},
loadingText: {
color: `#666`,
fontSize: 14,
},
})
79 changes: 52 additions & 27 deletions examples/react-native/offline-transactions/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,60 @@ config.watchFolders = [monorepoRoot]

// Ensure symlinks are followed (important for pnpm)
config.resolver.unstable_enableSymlinks = true
config.resolver.unstable_enablePackageExports = true

// Force all React-related packages to resolve from THIS project's node_modules
// This prevents the "multiple copies of React" error
const localNodeModules = path.resolve(projectRoot, 'node_modules')
config.resolver.extraNodeModules = new Proxy(
{
react: path.resolve(localNodeModules, 'react'),
'react-native': path.resolve(localNodeModules, 'react-native'),
'react/jsx-runtime': path.resolve(localNodeModules, 'react/jsx-runtime'),
'react/jsx-dev-runtime': path.resolve(
localNodeModules,
'react/jsx-dev-runtime',
),
},
{
get: (target, name) => {
if (target[name]) {
return target[name]
}
// Fall back to normal resolution for other modules
return path.resolve(localNodeModules, name)
},

// Singleton packages that must resolve to exactly one copy.
// In a pnpm monorepo, workspace packages may resolve these to a different
// version in the .pnpm store. This custom resolveRequest forces every import
// of these packages (from anywhere) to the app's local node_modules copy.
const singletonPackages = ['react', 'react-native']
const singletonPaths = {}
for (const pkg of singletonPackages) {
singletonPaths[pkg] = path.resolve(localNodeModules, pkg)
}

const defaultResolveRequest = config.resolver.resolveRequest
config.resolver.resolveRequest = (context, moduleName, platform) => {
// Force singleton packages to resolve from the app's local node_modules,
// regardless of where the import originates. This prevents workspace
// packages (e.g. react-db) from pulling in their own copy of React.
for (const pkg of singletonPackages) {
if (moduleName === pkg || moduleName.startsWith(pkg + '/')) {
try {
const filePath = require.resolve(moduleName, {
paths: [projectRoot],
})
return { type: 'sourceFile', filePath }
} catch {}
}
}

if (defaultResolveRequest) {
return defaultResolveRequest(context, moduleName, platform)
}
return context.resolveRequest(
{ ...context, resolveRequest: undefined },
moduleName,
platform,
)
}

// Force singleton packages to resolve from the app's local node_modules
config.resolver.extraNodeModules = new Proxy(singletonPaths, {
get: (target, name) => {
if (target[name]) {
return target[name]
}
return path.resolve(localNodeModules, name)
},
)
})

// Block react-native 0.83 from root node_modules
const escMonorepoRoot = monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
config.resolver.blockList = [
new RegExp(
`${monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/node_modules/\\.pnpm/react-native@0\\.83.*`,
),
new RegExp(
`${monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/node_modules/\\.pnpm/react@(?!19\\.0\\.0).*`,
),
new RegExp(`${escMonorepoRoot}/node_modules/\\.pnpm/react-native@0\\.83.*`),
]

// Let Metro know where to resolve packages from (local first, then root)
Expand All @@ -52,4 +73,8 @@ config.resolver.nodeModulesPaths = [
path.resolve(monorepoRoot, 'node_modules'),
]

// Allow dynamic imports with non-literal arguments (used by workspace packages
// for optional Node.js-only code paths that are never reached on React Native)
config.transformer.dynamicDepsInPackages = 'throwAtRuntime'

module.exports = config
5 changes: 4 additions & 1 deletion examples/react-native/offline-transactions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
},
"dependencies": {
"@expo/metro-runtime": "~5.0.5",
"@op-engineering/op-sqlite": "^15.2.5",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-community/netinfo": "11.4.1",
"@tanstack/db": "workspace:*",
"@tanstack/db-react-native-sqlite-persisted-collection": "workspace:*",
"@tanstack/offline-transactions": "^1.0.24",
"@tanstack/query-db-collection": "^1.0.30",
"@tanstack/react-db": "^0.1.77",
Expand All @@ -24,7 +27,7 @@
"expo-router": "~5.1.11",
"expo-status-bar": "~2.2.0",
"metro": "0.82.5",
"react": "^19.2.4",
"react": "19.0.0",
"react-native": "0.79.6",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
Expand Down
46 changes: 29 additions & 17 deletions examples/react-native/offline-transactions/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import express from 'express'
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import cors from 'cors'
import express from 'express'

const app = express()
const PORT = 3001
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), 'todos.json')

app.use(cors())
app.use(express.json())
Expand All @@ -24,21 +28,26 @@ function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36)
}

// Add some initial data
const initialTodos = [
{ id: '1', text: 'Learn TanStack DB', completed: false },
{ id: '2', text: 'Build offline-first app', completed: false },
{ id: '3', text: 'Test on React Native', completed: true },
]
// Load persisted data or seed with initial data
function loadData() {
try {
const raw = readFileSync(DATA_FILE, 'utf-8')
const todos: Array<Todo> = JSON.parse(raw)
todos.forEach((todo) => todosStore.set(todo.id, todo))
console.log(`Loaded ${todos.length} todos from ${DATA_FILE}`)
} catch {
console.log(`No existing data file, starting empty`)
}
}

initialTodos.forEach((todo) => {
const now = new Date().toISOString()
todosStore.set(todo.id, {
...todo,
createdAt: now,
updatedAt: now,
})
})
function saveData() {
writeFileSync(
DATA_FILE,
JSON.stringify(Array.from(todosStore.values()), null, 2),
)
}

loadData()

// Simulate network delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
Expand All @@ -58,20 +67,21 @@ app.post('/api/todos', async (req, res) => {
console.log('POST /api/todos', req.body)
await delay(200)

const { text, completed } = req.body
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: generateId(),
id: id || generateId(),
text,
completed: completed ?? false,
createdAt: now,
updatedAt: now,
}
todosStore.set(todo.id, todo)
saveData()
res.status(201).json(todo)
})

Expand All @@ -91,6 +101,7 @@ app.put('/api/todos/:id', async (req, res) => {
updatedAt: new Date().toISOString(),
}
todosStore.set(req.params.id, updated)
saveData()
res.json(updated)
})

Expand All @@ -102,6 +113,7 @@ app.delete('/api/todos/:id', async (req, res) => {
if (!todosStore.delete(req.params.id)) {
return res.status(404).json({ error: 'Todo not found' })
}
saveData()
res.json({ success: true })
})

Expand Down
Loading
Loading