关于 electric-new-feature
指导开发者端到端构建 Electric 新功能的技能,涵盖 Postgres 表、代理路由、数据集合与实时查询,并说明常见实现陷阱。
主要功能
- 01搭建本地 Electric 环境
- 02创建 Postgres 表与代理路由
- 03定义数据模式并创建集合
- 04构建实时查询与乐观变更
- 05说明同步实现中的常见错误
适用场景
- 01从零构建待办事项同步功能
- 02为 Electric 数据流接入代理路由
- 03排查 TanStack DB 与 Electric 的类型或写入问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ electric-sql/ electric --skill electric-new-feature安装后在 Claude Code 中输入/electric-new-feature确认已加载。
详细介绍
此技能基于 electric-shapes、electric-proxy-auth 和 electric-schema-shapes。请先阅读这些内容。
Electric — 端到端实现新功能
设置
0. 在本地启动 Electric
# 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
docker compose up -d
1. 创建 Postgres 表
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. 创建代理路由
代理会转发 Electric 协议参数,并注入服务端密钥。请使用你所用框架的服务端路由模式(TanStack Start、Next.js API route、Express 等)。
// 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. 定义 schema
// 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>
如果使用 Drizzle,可通过 drizzle-zod 中的 createSelectSchema(todosTable) 从表定义生成 schema。
4. 创建 mutation 端点
使用你所用框架的服务端函数或 API route 实现写入端点。该端点必须从执行 mutation 的同一事务中返回 { txid }。
// 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. 创建 TanStack DB 集合
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. 构建实时查询
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. 乐观 mutation
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)
常见错误
高优先级:因 TanStack DB schema 会处理类型而移除解析器
错误:
// "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!
})
正确:
electricCollectionOptions({
schema: z.object({ created_at: z.coerce.date() }),
shapeOptions: {
url: '/api/todos',
parser: { timestamptz: (date: string) => new Date(date) },
},
})
Electric 的同步路径会将数据直接写入集合存储,绕过 TanStack DB schema。shapeOptions 中的 parser 负责在同步路径上进行类型强制转换;schema 则处理 mutation 路径。两者都不可缺少。如果没有 parser,timestamptz 会以字符串形式传入,导致 getTime() 或其他 Date 方法在运行时失败。
严重:使用旧版 electrify() 双向同步 API
错误:
const { db } = await electrify(conn, schema)
await db.todos.create({ text: 'New todo' })
正确:
todoCollection.insert({ id: crypto.randomUUID(), text: 'New todo' })
// Write path: collection.insert() → onInsert → API → Postgres → txid → awaitTxId
旧版 ElectricSQL(v0.x)支持 SQLite 双向同步。当前版本的 Electric 为只读。写入通过 API 端点完成,并通过 txid 握手机制进行协调。
来源:AGENTS.md:386-392
高优先级:使用基于路径的表 URL 模式
错误:
const stream = new ShapeStream({
url: 'http://localhost:3000/v1/shape/todos?offset=-1',
})
正确:
const stream = new ShapeStream({
url: 'http://localhost:3000/v1/shape?table=todos&offset=-1',
})
将表名作为路径段的模式(/v1/shape/todos)已在 v0.8.0 中移除。现在表名是查询参数。
来源:packages/sync-service/CHANGELOG.md:1124
中优先级:使用 shape_id 而不是 handle
错误:
const stream = new ShapeStream({
url: '/api/todos',
params: { shape_id: '12345' },
})
正确:
const stream = new ShapeStream({
url: '/api/todos',
handle: '12345',
})
此名称在 v0.8.0 中从 shape_id 更改为 handle。
另请参阅:electric-orm/SKILL.md — 从 ORM 事务中获取 txid。 另请参阅:electric-proxy-auth/SKILL.md — 端到端功能流程包含设置代理路由。
版本
目标版本:@electric-sql/client v1.5.10、@tanstack/react-db 最新版。