Electric Todo App:基于 useShape 与 Shape API 代理的经典 TodoMVC 示例解析
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
官方演示文档 website/sync/demos/todo-app.md 介绍了 Electric 仓库中的一个经典TodoMVC 示例应用:前端用 React 的useShapeHook 实时订阅 Postgres 表,写操作走 Express 后端直连数据库,读路径则通过后端代理到 Electric 的 Shape API。读完本篇,你可以完整掌握“Electric 同步读 + REST 直写”这一同步架构的落地方式,并在本地按仓库步骤跑起这个示例。
示例定位与整体架构
这是 ElectricSQL monorepo 中的一个示例工程,位于 examples/todo-app,其package.json的描述为"Somewhat opinionated starter for ElectricSQL with Vite, and React Router"。官方文档页还标注了演示站点与源码位置(见 website/sync/demos/todo-app.md 的 frontmatter)。
从源码结构看,整条数据链路如下:
React 前端 (useShape) │ GET /todos ──────────► Express 后端 (server.js, :3010) │ │ 仅转发 ELECTRIC_PROTOCOL_QUERY_PARAMS │ ◄── SSE 形状流 ◄── 代理 ◄──┘ │ POST/PUT/DELETE /todos │ Electric Shape API (:3000) └──► Postgres (todos 表)- 读路径:前端不直接访问 Electric,而是请求后端的
/todos,后端把请求代理到 Electric 的/v1/shape端点,并把表名todos固定在服务端; - 写路径:POST/PUT/DELETE 请求由 Express 直接执行 SQL,变更由 Electric 的复制机制捕获后推送到所有形状流。
数据模型:todos 表
表结构定义在迁移文件 001-create-todos.sql:
CREATE TABLE IF NOT EXISTS todos ( id UUID PRIMARY KEY, title TEXT NOT NULL, completed BOOLEAN NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL );四个字段与前端类型一一对应:主键id使用 UUID(由前端生成),completed与created_at均为NOT NULL,保证形状流中的行数据完整。迁移通过db:migrate脚本应用(见后文运行部分),部署场景下则由 sst.config.ts 调用createDatabaseForCloudElectric并指定migrationsDirectory: ./db/migrations执行。
前端核心:useShape 订阅表数据
官方文档指出的“主要 Electric 代码”位于 src/routes/index.tsx。完整的同步入口只有几行(index.tsx#L24-L28):
export default function Index() { const { data: todos } = useShape<ToDo>({ url: new URL(`${import.meta.env.VITE_SERVER_URL}/todos`).href, }) todos.sort((a, b) => a.created_at - b.created_at)对应的数据模型是:
type ToDo = { id: string title: string completed: boolean created_at: number }这里的关键点:
useShape来自@electric-sql/react(对应 monorepo 中的 packages/react-hooks 包)。它把url作为形状端点发起长连接,自动完成初始快照拉取、断线重连与增量更新,data中的todos数组会随 Postgres 中todos表的变更实时变化;VITE_SERVER_URL指向 Express 后端(本地开发为:3010,部署后为后端服务域名),前端始终只暴露后端的/todos路径;- UI 层使用 Radix UI 组件(
Card、Checkbox等)渲染列表。
三种写操作都是普通fetch,不经过 Electric:
切换完成状态(PUT,index.tsx#L32-L46):
const onTodoClicked = useCallback(async (todo: ToDo) => { await fetch( new URL(`${import.meta.env.VITE_SERVER_URL}/todos/${todo.id}`).href, { method: `PUT`, headers: { "Content-Type": `application/json` }, body: JSON.stringify({ completed: !todo.completed }), } ) }, [])删除(DELETE,index.tsx#L48-L56):
const onTodoDeleted = useCallback(async (todo: ToDo) => { await fetch( new URL(`${import.meta.env.VITE_SERVER_URL}/todos/${todo.id}`).href, { method: `DELETE` } ) }, [])新建(POST,index.tsx#L100-L121),注意 id 由前端用uuidv4()生成:
const id = uuidv4() const formElem = event.target as HTMLFormElement const formData = Object.fromEntries(new FormData(formElem)) formElem.reset() const res = await fetch( new URL(`${import.meta.env.VITE_SERVER_URL}/todos`).href, { method: `POST`, headers: { "Content-Type": `application/json` }, body: JSON.stringify({ id, title: formData.todo }), } )点击卡片切换完成态、点X删除、底部表单新建——这些交互产生的任何一次数据库变更,都会经由 Electric 推送给本页面以及其他所有订阅该表的客户端,实现多方实时同步。
后端:Express 代理 + 直写 SQL
完整实现见 server.js,服务固定监听3010端口。
GET /todos:只转发协议参数的 Shape 代理
这是示例中最有教学价值的一段(server.js#L32-L86):
// GET /todos - proxy to Electric for syncing todos app.get(`/todos`, async (req, res) => { const ELECTRIC_URL = process.env.ELECTRIC_URL || `http://localhost:3000` const electricUrl = new URL(`${ELECTRIC_URL}/v1/shape`) // Only pass through Electric protocol parameters Object.keys(req.query).forEach((key) => { if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { electricUrl.searchParams.set(key, req.query[key]) } }) // Set the table server-side electricUrl.searchParams.set(`table`, `todos`) // Add source credentials if available if (process.env.ELECTRIC_SOURCE_ID) { electricUrl.searchParams.set(`source_id`, process.env.ELECTRIC_SOURCE_ID) } if (process.env.ELECTRIC_SOURCE_SECRET) { electricUrl.searchParams.set(`secret`, process.env.ELECTRIC_SOURCE_SECRET) } try { const response = await fetch(electricUrl) // Remove problematic headers that could break decoding const headers = {} response.headers.forEach((value, key) => { if ( key.toLowerCase() !== `content-encoding` && key.toLowerCase() !== `content-length` ) { headers[key] = value } }) res.writeHead(response.status, response.statusText, headers) // Convert Web Streams to Node.js stream and pipe const nodeStream = Readable.fromWeb(response.body) await pipeline(nodeStream, res) } catch (error) { // Ignore premature close errors - these happen when clients disconnect early if (error.code === `ERR_STREAM_PREMATURE_CLOSE`) { return } ... } })其中ELECTRIC_PROTOCOL_QUERY_PARAMS从@electric-sql/client导入,定义在 packages/typescript-client/src/constants.ts#L37 并由包入口重新导出。它的用途是让代理只透传客户端协议必需的查询参数(如续订游标等),而丢弃其余参数——源码中的注释明确解释了动机:如果原样转发所有参数,客户端就能通过table、where、columns控制形状端点,从而越权访问任意 Postgres 表(可参考 skills 文档中的代理鉴权说明)。
该代理还体现了三层安全/健壮性设计:
- 表名服务端固定:
electricUrl.searchParams.set('table', 'todos')覆盖一切客户端尝试,前端永远只能同步这张表; - 凭据注入:
source_id/secret来自环境变量(部署时由 SST 注入),前端无从接触; - 流式转发细节:剥除
content-encoding与content-长度头(因为 Node 侧已解压,保留会破坏客户端解码),用Readable.fromWeb+pipeline把 SSE 流逐字节泵到响应,并对客户端提前断开产生的ERR_STREAM_PREMATURE_CLOSE静默处理。
写端点:Zod 校验 + 参数化 SQL
POST/PUT/DELETE 三个端点都先做 schema 校验再执行 SQL:
const idSchema = z.string().uuid() const postSchema = z.object({ id: z.string().uuid(), title: z.string(), }) const putSchema = z.object({ title: z.string().optional(), completed: z.boolean().optional(), })- POST /todos(server.js#L88-L108):校验失败返回 400 与 zod 错误信息;通过则
insert into todos (id, title, completed, created_at) VALUES($1, $2, false, $3),completed固定为false,created_at取服务端当前时间; - PUT /todos/:id(server.js#L110-L125):动态 UPDATE 由
generateUpdateQuery(server.js#L201-L213)生成,把putSchema中出现的任意可选字段拼成SET "col" = $n子句并全部参数化; - DELETE /todos/:id(server.js#L127-L137):
DELETE from todos where id = $1; - 另有
GET /health返回 200,供部署环境做健康检查。
本地运行:monorepo + Docker Compose
示例是 pnpm workspace 的一部分,运行步骤与 examples/todo-app/README.md 一致:
- 在 monorepo 根目录安装并构建所有工作区包:
pnpm install pnpm run -r build- 进入
examples/todo-app,用 Docker Compose 拉起示例后端:
pnpm backend:up该脚本(见 examples/todo-app/package.json)实际执行PROJECT_NAME=todo-app-example pnpm -C ../../ run example-backend:up && pnpm db:migrate。根目录的example-backend:up会先down --volumes再启动,README 特别提示这会停掉并删除其他示例容器挂载的卷,确保示例总是从干净数据库启动。组合文件为 .support/docker-compose.yml,两个服务分别是:
postgres:16-alpine,映射54321:5432,数据库electric;electricsql/electric:canary(构建上下文为packages/sync-service/),ELECTRIC_INSECURE: true,映射3000:3000。README 与 compose 注释都注明 insecure 模式仅适用于开发环境。
随后的db:migrate通过pg-migrations(@databases/pg-migrations)应用db/migrations下的迁移。
- 启动开发服务器:
pnpm dev即dotenv -e .env -- concurrently "vite" "node server.js",Vite 负责前端热更新,Express 后端同时起在3010端口。
- 用完停掉后端服务:
pnpm backend:down部署形态:Dockerfile 与 SST 配置
示例附带两种部署配置,可作为生产参考:
- Dockerfile:基于
node:lts-alpine多阶段构建,把 monorepo 中依赖的packages/typescript-client、packages/react-hooks与examples/todo-app一并拷贝、pnpm install --frozen-lockfile后全量构建,最终镜像EXPOSE 3010、ENTRYPOINT ["node", "server.js"]; - sst.config.ts:通过 SST 把同一镜像部署为 EKS 服务(负载均衡器转发
443/https -> 3010/http,健康检查路径/health),并注入DATABASE_URL、ELECTRIC_URL、ELECTRIC_SOURCE_ID、ELECTRIC_SOURCE_SECRET四个环境变量;静态站点构建时把VITE_SERVER_URL设置为后端服务 URL,从而让useShape的读路径与写 REST 路径都指向同一个代理后端。
小结
这个示例以极小的体量串起了 Electric 同步方案的完整闭环:useShape一行代码完成实时订阅,todos表结构定义形状边界,Express 代理用ELECTRIC_PROTOCOL_QUERY_PARAMS白名单 + 服务端固定表名实现“前端拿不到 Electric 地址与凭据”的安全隔离,而写路径则保持最朴素的 REST + 参数化 SQL。若要进一步深入,建议阅读 packages/typescript-client 中形状客户端的SPEC.md与 packages/react-hooks 的 Hook 实现,理解data背后的连接管理与重连策略。
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考