300 lines
7.9 KiB
Markdown
300 lines
7.9 KiB
Markdown
---
|
|
title: Migration Guide
|
|
description: Incremental migration from server-first to local-first architecture using TanStack DB and ElectricSQL
|
|
tags:
|
|
[
|
|
migration,
|
|
tanstack-db,
|
|
electric,
|
|
incremental,
|
|
rollback,
|
|
feature-flag,
|
|
pglite,
|
|
testing,
|
|
]
|
|
---
|
|
|
|
## Incremental Adoption Path
|
|
|
|
Migrate one component or route at a time. The local-first stack coexists with existing server-first code — no big-bang rewrite required.
|
|
|
|
```text
|
|
Phase 1: Install TanStack DB alongside TanStack Query
|
|
Phase 2: Create query collections (reuse existing queryFn)
|
|
Phase 3: Replace useQuery with useLiveQuery
|
|
Phase 4: Swap query collections for Electric collections
|
|
```
|
|
|
|
## Step-by-Step Migration
|
|
|
|
### Step 1: Install TanStack DB
|
|
|
|
```bash
|
|
pnpm add @tanstack/db @tanstack/db-electric
|
|
```
|
|
|
|
### Step 2: Create a Query Collection
|
|
|
|
Reuse the existing `queryFn` from TanStack Query. No backend changes needed.
|
|
|
|
```ts
|
|
import { createQueryCollection } from '@tanstack/db';
|
|
|
|
const todosCollection = createQueryCollection({
|
|
id: 'todos',
|
|
queryFn: async () => {
|
|
const response = await fetch('/api/todos');
|
|
return response.json() as Promise<Todo[]>;
|
|
},
|
|
getId: (todo) => todo.id,
|
|
schema: todoSchema,
|
|
});
|
|
```
|
|
|
|
### Step 3: Replace useQuery with useLiveQuery
|
|
|
|
```ts
|
|
// Before: server-first
|
|
const { data: todos } = useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: () => fetch('/api/todos').then((r) => r.json()),
|
|
});
|
|
|
|
// After: local-first (same data, instant reads)
|
|
const todos = useLiveQuery((q) =>
|
|
q.from({ todosCollection }).where('@completed', '=', false).toArray(),
|
|
);
|
|
```
|
|
|
|
Component JSX stays identical. Only the data-fetching hook changes.
|
|
|
|
### Step 4: Swap to Electric Collection
|
|
|
|
When ready for real-time sync, replace the query collection with an Electric collection. No component changes required.
|
|
|
|
```ts
|
|
import { createElectricCollection } from '@tanstack/db-electric';
|
|
|
|
const todosCollection = createElectricCollection({
|
|
id: 'todos',
|
|
electricUrl: 'http://localhost:3000/v1/shape',
|
|
electricParams: { table: 'todos' },
|
|
getId: (todo) => todo.id,
|
|
schema: todoSchema,
|
|
});
|
|
```
|
|
|
|
The `useLiveQuery` calls remain untouched.
|
|
|
|
## Database Preparation for Electric
|
|
|
|
ElectricSQL requires logical replication on Postgres.
|
|
|
|
### Postgres Configuration
|
|
|
|
```sql
|
|
ALTER SYSTEM SET wal_level = 'logical';
|
|
ALTER SYSTEM SET max_replication_slots = 10;
|
|
ALTER SYSTEM SET max_wal_senders = 10;
|
|
```
|
|
|
|
Restart Postgres after changing `wal_level`.
|
|
|
|
### Replication User
|
|
|
|
```sql
|
|
CREATE ROLE electric_user WITH LOGIN PASSWORD 'electric_pass' REPLICATION;
|
|
GRANT USAGE ON SCHEMA public TO electric_user;
|
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO electric_user;
|
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO electric_user;
|
|
```
|
|
|
|
### Docker Compose Example
|
|
|
|
```yaml
|
|
services:
|
|
postgres:
|
|
image: postgres:16
|
|
environment:
|
|
POSTGRES_DB: app
|
|
POSTGRES_USER: postgres
|
|
POSTGRES_PASSWORD: postgres
|
|
command: >
|
|
postgres
|
|
-c wal_level=logical
|
|
-c max_replication_slots=10
|
|
-c max_wal_senders=10
|
|
ports:
|
|
- '5432:5432'
|
|
|
|
electric:
|
|
image: electricsql/electric
|
|
environment:
|
|
DATABASE_URL: postgresql://electric_user:electric_pass@postgres:5432/app
|
|
ports:
|
|
- '3000:3000'
|
|
depends_on:
|
|
- postgres
|
|
```
|
|
|
|
## Coexistence Patterns
|
|
|
|
Query collections and Electric collections can coexist in the same application. Use query collections for server-fetched data that doesn't need real-time sync, and Electric collections for locally-synced data.
|
|
|
|
### Cross-Collection Joins
|
|
|
|
```ts
|
|
const queryTodos = createQueryCollection({
|
|
id: 'todos-query',
|
|
queryFn: fetchTodos,
|
|
getId: (t) => t.id,
|
|
schema: todoSchema,
|
|
});
|
|
|
|
const electricUsers = createElectricCollection({
|
|
id: 'users-electric',
|
|
electricUrl: 'http://localhost:3000/v1/shape',
|
|
electricParams: { table: 'users' },
|
|
getId: (u) => u.id,
|
|
schema: userSchema,
|
|
});
|
|
|
|
const todosWithUsers = useLiveQuery((q) =>
|
|
q
|
|
.from({ queryTodos })
|
|
.join({ electricUsers }, '@userId', '=', '@id')
|
|
.select('@queryTodos.*', '@electricUsers.name')
|
|
.toArray(),
|
|
);
|
|
```
|
|
|
|
## Feature Flagging
|
|
|
|
Toggle between server-first and local-first per collection.
|
|
|
|
```ts
|
|
type SyncMode = 'query' | 'electric';
|
|
|
|
function createTodoCollection(mode: SyncMode) {
|
|
if (mode === 'electric') {
|
|
return createElectricCollection({
|
|
id: 'todos',
|
|
electricUrl: 'http://localhost:3000/v1/shape',
|
|
electricParams: { table: 'todos' },
|
|
getId: (t) => t.id,
|
|
schema: todoSchema,
|
|
});
|
|
}
|
|
|
|
return createQueryCollection({
|
|
id: 'todos',
|
|
queryFn: fetchTodos,
|
|
getId: (t) => t.id,
|
|
schema: todoSchema,
|
|
});
|
|
}
|
|
|
|
const todosCollection = createTodoCollection(
|
|
featureFlags.localFirst ? 'electric' : 'query',
|
|
);
|
|
```
|
|
|
|
## REST to Shape Mapping
|
|
|
|
| REST Pattern | Electric Shape Equivalent |
|
|
| ------------------------------ | ------------------------------------------------------ |
|
|
| `GET /todos` | `{ table: 'todos' }` |
|
|
| `GET /todos?completed=false` | `{ table: 'todos', where: 'completed = false' }` |
|
|
| `GET /todos?fields=id,title` | `{ table: 'todos', columns: ['id', 'title'] }` |
|
|
| `GET /todos?userId=123` | `{ table: 'todos', where: 'user_id = 123' }` |
|
|
| `GET /todos?limit=50&offset=0` | Shapes sync all matching rows (paginate on the client) |
|
|
| `GET /todos?sort=created_at` | Sort on the client after sync |
|
|
|
|
## Rollback Strategy
|
|
|
|
Swap `createElectricCollection` back to `createQueryCollection`. Component code using `useLiveQuery` does not change.
|
|
|
|
```ts
|
|
// Rollback: replace Electric collection with query collection
|
|
const todosCollection = createQueryCollection({
|
|
id: 'todos',
|
|
queryFn: fetchTodos,
|
|
getId: (t) => t.id,
|
|
schema: todoSchema,
|
|
});
|
|
```
|
|
|
|
No component-level changes are needed because `useLiveQuery` works with both collection types.
|
|
|
|
## Testing During Migration
|
|
|
|
### PGlite for Unit Tests
|
|
|
|
```ts
|
|
import { PGlite } from '@electric-sql/pglite';
|
|
|
|
async function createTestDB(): Promise<PGlite> {
|
|
const pg = new PGlite();
|
|
await pg.exec(`
|
|
CREATE TABLE todos (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
title TEXT NOT NULL,
|
|
completed BOOLEAN DEFAULT false
|
|
)
|
|
`);
|
|
return pg;
|
|
}
|
|
```
|
|
|
|
### Dual-Write Verification
|
|
|
|
During migration, write to both the old server path and the new local-first path. Compare results to verify consistency.
|
|
|
|
```ts
|
|
async function dualWriteVerify(todo: Todo): Promise<{
|
|
match: boolean;
|
|
server: Todo;
|
|
local: Todo;
|
|
}> {
|
|
const [serverResult, localResult] = await Promise.all([
|
|
fetch('/api/todos', {
|
|
method: 'POST',
|
|
body: JSON.stringify(todo),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}).then((r) => r.json() as Promise<Todo>),
|
|
todosCollection.insert(todo),
|
|
]);
|
|
|
|
return {
|
|
match: serverResult.id === localResult.id,
|
|
server: serverResult,
|
|
local: localResult,
|
|
};
|
|
}
|
|
```
|
|
|
|
### A/B Testing
|
|
|
|
Route a percentage of users to the local-first path and compare performance metrics.
|
|
|
|
```ts
|
|
function getCollectionForUser(userId: string): typeof todosCollection {
|
|
const bucket = hashString(userId) % 100;
|
|
const useLocalFirst = bucket < 10;
|
|
return createTodoCollection(useLocalFirst ? 'electric' : 'query');
|
|
}
|
|
```
|
|
|
|
## Performance Comparison
|
|
|
|
| Metric | Server-First | Local-First | Improvement |
|
|
| -------------------- | ------------ | -------------- | -------------- |
|
|
| Read latency | 100-500ms | <1ms | 100-500x |
|
|
| Write latency | 100-500ms | <1ms (local) | 100-500x |
|
|
| Offline reads | Fails | Works | N/A |
|
|
| Offline writes | Fails | Queues locally | N/A |
|
|
| Initial load | Fast | Slower (sync) | Tradeoff |
|
|
| Data freshness | Real-time | Near real-time | ~50-200ms lag |
|
|
| Bundle size increase | 0 | 50-200 KB | Tradeoff |
|
|
| Server load | Higher | Lower | Fewer requests |
|