@electric-sql/client
Version:
Postgres everywhere - your data, in sync, wherever you need it.
367 lines (297 loc) • 9.63 kB
Markdown
name: electric-new-feature
description: >
End-to-end guide for adding a new synced feature with Electric and TanStack
DB. Covers the full journey: design Postgres schema, set REPLICA IDENTITY
FULL, define shape, create proxy route, set up TanStack DB collection with
electricCollectionOptions, implement optimistic mutations with txid
handshake (pg_current_xact_id, awaitTxId), and build live queries with
useLiveQuery. Also covers migration from old ElectricSQL (electrify/db
pattern does not exist), current API patterns (table as query param not
path, handle not shape_id). Load when building a new feature from scratch.
type: lifecycle
library: electric
library_version: '1.5.10'
requires:
- electric-shapes
- electric-proxy-auth
- electric-schema-shapes
sources:
- 'electric-sql/electric:AGENTS.md'
- 'electric-sql/electric:examples/tanstack-db-web-starter/'
This skill builds on electric-shapes, electric-proxy-auth, and electric-schema-shapes. Read those first.
# Electric — New Feature End-to-End
## Setup
### 0. Start Electric locally
```yaml
# docker-compose.yml
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: electric
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- '54321:5432'
tmpfs:
- /tmp
command:
- -c
- listen_addresses=*
- -c
- wal_level=logical
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: postgresql://postgres:password@postgres:5432/electric?sslmode=disable
ELECTRIC_INSECURE: true # Dev only — use ELECTRIC_SECRET in production
ports:
- '3000:3000'
depends_on:
- postgres
```
```bash
docker compose up -d
```
### 1. Create Postgres table
```sql
CREATE TABLE todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
text TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE todos REPLICA IDENTITY FULL;
```
### 2. Create proxy route
The proxy forwards Electric protocol params and injects server-side secrets. Use your framework's server route pattern (TanStack Start, Next.js API route, Express, etc.).
```ts
// Example: TanStack Start — src/routes/api/todos.ts
import { createFileRoute } from '@tanstack/react-router'
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client'
const serve = async ({ request }: { request: Request }) => {
const url = new URL(request.url)
const electricUrl = process.env.ELECTRIC_URL || 'http://localhost:3000'
const origin = new URL(`${electricUrl}/v1/shape`)
url.searchParams.forEach((v, k) => {
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(k))
origin.searchParams.set(k, v)
})
origin.searchParams.set('table', 'todos')
// Add auth if using Electric Cloud
if (process.env.ELECTRIC_SOURCE_ID && process.env.ELECTRIC_SECRET) {
origin.searchParams.set('source_id', process.env.ELECTRIC_SOURCE_ID)
origin.searchParams.set('secret', process.env.ELECTRIC_SECRET)
}
const res = await fetch(origin)
const headers = new Headers(res.headers)
headers.delete('content-encoding')
headers.delete('content-length')
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
})
}
export const Route = createFileRoute('/api/todos')({
server: {
handlers: {
GET: serve,
},
},
})
```
### 3. Define schema
```ts
// db/schema.ts — Zod schema matching your Postgres table
import { z } from 'zod'
export const todoSchema = z.object({
id: z.string().uuid(),
user_id: z.string().uuid(),
text: z.string(),
completed: z.boolean(),
created_at: z.date(),
})
export type Todo = z.infer<typeof todoSchema>
```
If using Drizzle, generate schemas from your table definitions with `createSelectSchema(todosTable)` from `drizzle-zod`.
### 4. Create mutation endpoint
Implement your write endpoint using your framework's server function or API route. The endpoint must return `{ txid }` from the same transaction as the mutation.
```ts
// Example: server function that inserts and returns txid
async function createTodo(todo: { text: string; user_id: string }) {
const client = await pool.connect()
try {
await client.query('BEGIN')
const result = await client.query(
'INSERT INTO todos (text, user_id) VALUES ($1, $2) RETURNING id',
[todo.text, todo.user_id]
)
const txResult = await client.query(
'SELECT pg_current_xact_id()::xid::text AS txid'
)
await client.query('COMMIT')
return { id: result.rows[0].id, txid: Number(txResult.rows[0].txid) }
} finally {
client.release()
}
}
```
### 5. Create TanStack DB collection
```ts
import { createCollection } from '@tanstack/react-db'
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
import { todoSchema } from './db/schema'
export const todoCollection = createCollection(
electricCollectionOptions({
id: 'todos',
schema: todoSchema,
getKey: (row) => row.id,
shapeOptions: {
url: new URL(
'/api/todos',
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost:5173'
).toString(),
// Electric auto-parses: bool, int2, int4, float4, float8, json, jsonb
// You only need custom parsers for types like timestamptz, date, numeric
// See electric-shapes/references/type-parsers.md for the full list
parser: {
timestamptz: (date: string) => new Date(date),
},
},
onInsert: async ({ transaction }) => {
const { modified: newTodo } = transaction.mutations[0]
const result = await createTodo({
text: newTodo.text,
user_id: newTodo.user_id,
})
return { txid: result.txid }
},
onUpdate: async ({ transaction }) => {
const { modified: updated } = transaction.mutations[0]
const result = await updateTodo(updated.id, {
text: updated.text,
completed: updated.completed,
})
return { txid: result.txid }
},
onDelete: async ({ transaction }) => {
const { original: deleted } = transaction.mutations[0]
const result = await deleteTodo(deleted.id)
return { txid: result.txid }
},
})
)
```
### 6. Build live queries
```tsx
import { useLiveQuery, eq } from '@tanstack/react-db'
export function TodoList() {
const { data: todos } = useLiveQuery((q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.completed, false))
.orderBy(({ todo }) => todo.created_at, 'desc')
.limit(50)
)
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)
}
```
### 7. Optimistic mutations
```tsx
const handleAdd = () => {
todoCollection.insert({
id: crypto.randomUUID(),
text: 'New todo',
completed: false,
created_at: new Date(),
})
}
const handleToggle = (todo) => {
todoCollection.update(todo.id, (draft) => {
draft.completed = !draft.completed
})
}
const handleDelete = (todoId) => todoCollection.delete(todoId)
```
## Common Mistakes
### HIGH Removing parsers because the TanStack DB schema handles types
Wrong:
```ts
// "My Zod schema has z.coerce.date() so I don't need a parser"
electricCollectionOptions({
schema: z.object({ created_at: z.coerce.date() }),
shapeOptions: { url: '/api/todos' }, // No parser!
})
```
Correct:
```ts
electricCollectionOptions({
schema: z.object({ created_at: z.coerce.date() }),
shapeOptions: {
url: '/api/todos',
parser: { timestamptz: (date: string) => new Date(date) },
},
})
```
Electric's sync path delivers data directly into the collection store, bypassing the TanStack DB schema. The `parser` in `shapeOptions` handles type coercion on the sync path; the schema handles the mutation path. You need both. Without the parser, `timestamptz` arrives as a string and `getTime()` or other Date methods will fail at runtime.
### CRITICAL Using old electrify() bidirectional sync API
Wrong:
```ts
const { db } = await electrify(conn, schema)
await db.todos.create({ text: 'New todo' })
```
Correct:
```ts
todoCollection.insert({ id: crypto.randomUUID(), text: 'New todo' })
// Write path: collection.insert() → onInsert → API → Postgres → txid → awaitTxId
```
Old ElectricSQL (v0.x) had bidirectional SQLite sync. Current Electric is read-only. Writes go through your API endpoint and are reconciled via txid handshake.
Source: `AGENTS.md:386-392`
### HIGH Using path-based table URL pattern
Wrong:
```ts
const stream = new ShapeStream({
url: 'http://localhost:3000/v1/shape/todos?offset=-1',
})
```
Correct:
```ts
const stream = new ShapeStream({
url: 'http://localhost:3000/v1/shape?table=todos&offset=-1',
})
```
The table-as-path-segment pattern (`/v1/shape/todos`) was removed in v0.8.0. Table is now a query parameter.
Source: `packages/sync-service/CHANGELOG.md:1124`
### MEDIUM Using shape_id instead of handle
Wrong:
```ts
const stream = new ShapeStream({
url: '/api/todos',
params: { shape_id: '12345' },
})
```
Correct:
```ts
const stream = new ShapeStream({
url: '/api/todos',
handle: '12345',
})
```
Renamed from `shape_id` to `handle` in v0.8.0.
Source: `packages/sync-service/CHANGELOG.md:1123`
See also: electric-orm/SKILL.md — Getting txid from ORM transactions.
See also: electric-proxy-auth/SKILL.md — E2E feature journey includes setting up proxy routes.
## Version
Targets @electric-sql/client v1.5.10, @tanstack/react-db latest.