
使用 SpacetimeDB 构建 Discord 风格实时聊天应用从表结构到完整前端实现【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB导读本文以开源仓库中的完整示例应用Chat App位于 tools/llm-oneshot/apps/chat-app/typescript/opus-4-5/spacetime/chat-app-20260102-171317为对象系统讲解如何在 SpacetimeDB 上仅用 TypeScript 编写服务端表定义 Reducer并用 React 客户端订阅实时数据搭建一个功能完整的 Discord 风格聊天应用。读完本文你将掌握SpacetimeDB 表与索引的声明方式、reducer与生命周期回调的编写、scheduled定时任务表驱动“定时消息/自动过期/自动 Away”的机制以及 React 客户端通过SpacetimeDBProvideruseTable消费实时订阅的完整链路。一、应用概览一个“全功能”实时聊天示例Chat App 是一个用 SpacetimeDB React 构建的、功能近乎完整的 Discord 风格聊天应用。其关联 READMEchat-app-20260102-171317/README.md列出了 12 个核心功能模块本文将其作为骨架逐一展开功能模块核心能力底层支撑表 / Reducer基础聊天显示名、创建/加入/离开房间、实时消息、在线列表user、room、room_member、message输入指示“XX 正在输入…”、5 秒无操作自动过期typing_indicatortyping_cleanup_job已读回执消息下方“已读X、Y、Z”实时更新read_receipt、room_member.lastRead*未读计数房间列表角标按用户/房间记录最后阅读位置room_member定时消息未来时间发送、查看与取消待发送消息scheduled_messagescheduled_message_view阅后即焚1 分钟/5 分钟/1 小时自动删除带倒计时message.expiresAtephemeral_cleanup_job表情回应❤️ 切换与悬停查看message_reaction编辑历史编辑自己的消息、(edited)标记、历史回看message_edit实时权限房主即管理员、踢人/封禁/提升管理员、即时生效banned_user、room_member.isAdmin丰富在线状态Online/Away/DND/Invisible、离线显示最后活跃时间、5 分钟自动 Awayuser.statuspresence_away_job消息线程回复指定消息、回复计数、线程面板message.parentMessageId、replyCount私密房间与 DM邀请制私密房、按用户名邀请、双人私聊、接受/拒绝邀请room_invitation、room.isPrivate/isDm该示例还配套了一份评分记录GRADING_RESULTS.md前 12 个功能模块得分 34.5/3695.8%其中功能 1–8、10–12 均为满分唯一被扣分的功能 9实时权限并非后端问题而是前端在App.tsx中用selectedRoom.isPrivate条件把管理工具限制在了私密房间内——后端对公共房间的踢人/封禁逻辑是完整可用的。这为我们评估“前后端职责边界”提供了很好的参考。二、项目结构前后端如何组织chat-app-20260102-171317/ ├── backend/ │ └── spacetimedb/ │ ├── src/ │ │ ├── schema.ts # 表定义15 张表 │ │ └── index.ts # Reducer 与生命周期处理器约 1305 行 │ ├── package.json # 依赖 spacetimedb ^1.11.0仅 TypeScript │ └── tsconfig.json ├── client/ │ ├── src/ │ │ ├── main.tsx # 入口SpacetimeDBProvider 连接构建 │ │ ├── App.tsx # 主应用组件约 1235 行 │ │ ├── styles.css # 暗色主题样式 │ │ └── module_bindings/ # 生成绑定发布后需重新生成 │ ├── index.html │ ├── package.json # React 18 Vite 5 spacetimedb │ ├── tsconfig.json │ └── vite.config.ts └── README.md值得强调的是服务端完全使用 TypeScript后端package.json中唯一的运行时依赖是spacetimedb: ^1.11.0无需自建 HTTP/WebSocket 服务表与业务逻辑即“模块module”一次spacetime publish即部署到 SpacetimeDB 运行时。客户端则使用spacetimedb/react提供的 React 绑定将服务端表实时同步为前端状态。三、环境准备与启动步骤3.1 前置条件Node.js 18服务端与客户端均需要SpacetimeDB CLI本文所依托仓库的 CLI 实现位于 crates/cli/src含start、publish、generate、logs、list等子命令3.2 后端启动三步走启动 SpacetimeDB 服务spacetime start默认情况下服务监听本地地址供后续 publish 与客户端连接使用。发布模块cd backend/spacetimedb spacetime publish chat-app --module-path .其中chat-app是模块名README 中特别注明“Usechat-appas the module name”--module-path .指向包含schema.ts/index.ts与package.json的模块目录。发布后服务端会编译模块并建立全部 15 张表。生成客户端绑定spacetime generate --lang typescript --out-dir ../../client/src/module_bindings --module-path .该命令会根据服务端 schema 生成 TypeScript 绑定DbConnection、tables、reducers等。README 明确提示client/src/module_bindings下默认是占位文件发布后端后必须重新生成否则客户端无法与真实 schema 对齐。3.3 客户端启动cd client npm install npm run dev然后浏览器打开 http://localhost:3000 即可。此时页面会通过 WebSocket 连接本地 SpacetimeDB连接地址见 client/src/main.tsx 中的SPACETIMEDB_URI ws://localhost:3000。四、服务端设计schema.ts 的 15 张表服务端表定义全部集中在 backend/spacetimedb/src/schema.ts使用schema/table/t三个 API 组合声明。注意本示例中表名大小写不统一user小写、Room等大写但最终表名由name字段指定导出时全部注册进spacetimedbschema。4.1 用户与房间域user以identityt.identity()为主键含name可选显示名、online、status取值online | away | dnd | invisible、lastActiveAt、connectionId建立了名为by_name的 btree 索引用于按用户名检索start_dm、invite_to_room等 Reducer 依赖它。roomid为t.u64().primaryKey().autoInc()自增主键含name、ownerId、isPrivate、isDm、createdAtby_owner索引。room_member房间成员表by_room/by_user双索引字段isAdmin标记管理员lastReadMessageId/lastReadAt记录“每个用户在每个房间的最后阅读位置”——这正是未读计数与已读回执的数据来源。banned_user封禁记录含bannedBy、bannedAt、可选reason。room_invitation邀请记录status取值pending | accepted | declinedby_room/by_invitee双索引。4.2 消息域messageby_room/by_sender/by_parent三个索引parentMessageId可选与replyCount支撑线程expiresAt可选支撑阅后即焚。message_edit编辑历史messageId、previousContent、editedAt。message_reaction表情回应messageId、userId、emoji。read_receipt按消息记录“谁看过”messageId、userId、seenAt。4.3 定时任务表scheduled 表这是 SpacetimeDB 最具特色的部分——用普通表声明定时任务字段声明里通过scheduled: reducer 名指定到期时自动触发的 Reducer再以scheduledId自增主键scheduledAtt.scheduleAt()声明任务时间typing_cleanup_job→run_typing_cleanup输入指示 5 秒后自动清理scheduled_message→run_scheduled_message定时消息到期自动发送scheduled_message_view非调度表公开表供用户查看/取消自己的待发送消息只读视图 索引by_room/by_senderephemeral_cleanup_job→run_ephemeral_cleanup阅后即焚消息到期删除presence_away_job→run_presence_away用户 5 分钟无操作自动置为 Away。// 以定时消息表为例schema.ts 中精简示意 export const ScheduledMessage table( { name: scheduled_message, scheduled: run_scheduled_message, // 到期触发该 reducer }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), // 触发时间 roomId: t.u64(), senderId: t.identity(), content: t.string(), createdAt: t.timestamp(), } );最终通过schema(User, Room, ... , PresenceAwayJob)一次性导出服务端即完成全部建表。五、服务端逻辑index.ts 的 Reducer 设计业务逻辑集中在 backend/spacetimedb/src/index.ts约 1305 行所有 Reducer 均通过spacetimedb.reducer(name, { 参数 }, (ctx, args) {...})注册并大量使用SenderError抛出对调用方可见的校验错误。5.1 生命周期clientConnected / clientDisconnected连接建立时index.ts#L33-L56已存在的用户被置为onlineinvisible状态保持不变并保留隐身更新lastActiveAt与connectionId新用户自动插入一行name为空稍后通过set_name设置。随后调用schedulePresenceAway安排自动 Away。断开时index.ts#L58-L73置online: false、清空connectionId并顺手清理该用户残留的 typing 指示。spacetimedb.clientConnected(ctx { const user ctx.db.user.identity.find(ctx.sender); if (user) { ctx.db.user.identity.update({ ...user, online: true, status: user.status invisible ? invisible : online, lastActiveAt: ctx.timestamp, connectionId: ctx.connectionId, }); } else { ctx.db.user.insert({ identity: ctx.sender, name: undefined, online: true, status: online, ... }); } schedulePresenceAway(ctx, ctx.sender); });5.2 用户与房间set_name/set_status前者校验非空、长度 ≤ 50后者校验状态必须是[online,away,dnd,invisible]之一。update_activity客户端每分钟心跳一次见 App.tsx 中setInterval(() conn.reducers.updateActivity({}), 60000)将状态重置为online并重新调度 Away 任务。create_room房主即管理员isAdmin: true创建者自动入房join_room会依次校验私密性、封禁、重复入房leave_room删除对应room_member行。5.3 私密房间与 DMstart_dmindex.ts#L247-L318按用户名查找目标用户禁止给自己发 DM遍历现有 DM 去重然后创建isDm: true的房间并为双方各插入一条isAdmin: true的成员记录。invite_to_room仅管理员可邀请、仅私密房可邀请会检查被邀请者是否已是成员、是否已有 pending 邀请。respond_to_invitation仅受邀者本人可响应pending状态不可重复响应接受则更新状态并插入成员行拒绝则仅更新状态。5.4 权限管理kick_user/ban_user/unban_user/promote_to_admin遵循一致的模式先验证调用者是房间管理员再按by_name索引查找目标用户最后操作成员表或封禁表。例如ban_user会先删除目标用户的成员资格、再写入banned_user行所有权限变更都发生在单次事务性 Reducer 内因此权限更新即时生效——这正是“Real-Time Permissions”的底层保证。5.5 消息、反应、已读、输入指示send_message校验非空、长度 ≤ 4000、成员资格支持parentMessageId回复并原子地replyCount 1发送成功后清理自己的 typing 指示。send_ephemeral_messagedurationSeconds必须在 10 秒1 小时之间计算expiresAt timestamp duration写入message.expiresAt并插入ephemeral_cleanup_job。edit_message仅作者可编辑写入message_edit历史行后更新正文并置isEdited: true。delete_message作者或房间管理员可删级联清理反应、已读回执、编辑历史并维护父消息的replyCount。toggle_reaction白名单校验 8 个 emoji若已点过同一 emoji 则删除实现“切换”语义。start_typing/stop_typing写入typing_indicator并插入 5 秒清理任务TYPING_TIMEOUT_SECONDS 5n。mark_messages_read更新room_member的lastReadMessageId/lastReadAt并为指定消息幂等地插入read_receipt。5.6 定时任务 Reducer被 scheduled 表驱动的处理器run_scheduled_messageindex.ts#L1150-L1202到期后若房间仍存在且发送者仍是成员则将消息写入message表并同步从scheduled_message_view移除记录。run_ephemeral_cleanup到期后级联删除反应与已读回执再删除消息本身实现“永久删除”。run_typing_cleanup仅当指示已过期expiresAt now才删除避免误删刚刷新的指示。run_presence_away仅当用户仍在线且状态为online、且不活跃时长 ≥ 300 秒时才置为awayschedulePresenceAway辅助函数会先取消旧任务再插入新任务index.ts#L1279-L1300。六、客户端设计React 如何消费实时数据6.1 连接构建与令牌持久化入口文件 client/src/main.tsx 展示了标准连接流程const SPACETIMEDB_URI ws://localhost:3000; const MODULE_NAME chat-app; const connectionBuilder useMemo(() { const onConnect (conn, identity, token) { window.__db_conn conn; window.__my_identity identity; if (token) localStorage.setItem(auth_token, token); conn.subscriptionBuilder().subscribeToAllTables(); // 订阅全部表 }; const onConnectError (_ctx, err) { if (err.message?.includes(Unauthorized) || err.message?.includes(401)) { localStorage.removeItem(auth_token); window.location.reload(); } }; return DbConnection.builder() .withUri(SPACETIMEDB_URI) .withModuleName(MODULE_NAME) .withToken(localStorage.getItem(auth_token) || undefined) .onConnect(onConnect) .onConnectError(onConnectError) .onDisconnect(onDisconnect); }, []); SpacetimeDBProvider connectionBuilder{connectionBuilder} App / /SpacetimeDBProvider要点连接令牌持久化在localStorageauth_token中实现会话延续鉴权失败401/Unauthorized时自动清除令牌并刷新页面连接成功后通过subscribeToAllTables()一次性订阅全部表表量大时可按需订阅。6.2 useTable 实时订阅App.tsx 用useTable(tables.xxx)批量订阅服务端表const [users] useTable(tables.user); const [rooms] useTable(tables.room); const [messages] useTable(tables.message); const [typingIndicators] useTable(tables.typingIndicator); const [messageReactions] useTable(tables.messageReaction); // ...readReceipt / messageEdit / bannedUser / roomInvitation / scheduledMessageView服务端任何表变更都会实时推送到这些响应式数组客户端逻辑随后以useMemo派生出可见房间、我的成员关系、未读数、线程回复、编辑历史等视图状态。消息列表在messages变化时自动滚动到底部updateActivity心跳每 60 秒执行一次驱动服务端的“自动 Away”判定。6.3 UI 与交互README 中列出的 UI 特性与代码一一对应Discord 风格暗色主题styles.css、房间/DM 双 Tab 侧边栏、未读角标、状态指示、悬停反应选择器、线程面板、调度弹窗、加载/空状态等。管理类操作踢人/封禁/提升通过conn.reducers.kickUser(...)等生成绑定方法触发GRADING_RESULTS.md同时提示了一个可复用的经验将管理工具限制在私密房间的前端条件selectedRoom.isPrivate导致公共房间缺少管理入口后端逻辑本身并无问题。七、运行与验证建议按序执行spacetime start→spacetime publish chat-app --module-path .→spacetime generate --lang typescript --out-dir .../module_bindings --module-path .→npm install npm run dev。务必重新生成绑定README“Notes”强调module_bindings内是占位文件发布后需重新spacetime generate否则DbConnection/tables与真实 schema 不一致。移除 StrictModeREADME“Notes”指出应去掉React.StrictMode因为它会干扰 WebSocket 连接生命周期React 18 开发模式下 StrictMode 的双调用会重复建立连接。多开验证实时性开两个浏览器窗口分别设置不同显示名即可直观验证实时消息、输入指示、已读回执、表情回应与在线状态的即时同步。结合评分清单自查可对照 GRADING_RESULTS.md 中的 15 个功能验收标准逐项验证例如“踢人后立即失去访问权限”“定时消息准时出现”“阅后即焚到期彻底删除”。八、小结该示例带给 SpacetimeDB 开发者的启示从本文示例可以提炼出几条可直接复用的 SpacetimeDB 开发范式表即状态、Reducer 即业务全部服务端逻辑含权限校验收敛在 TypeScript 模块内客户端通过生成绑定调用天然获得实时广播能力scheduled 表驱动一切“到时执行”输入指示过期、定时消息、阅后即焚、自动 Away 全部用scheduled: reducer_namet.scheduleAt()声明式完成无需外部任务队列索引设计决定查询模式by_room/by_user/by_name等 btree 索引与 Reducer 中的.filter()一一对应多对多关系成员、封禁、邀请、反应、回执都以此为基础前后端契约由spacetime generate维系修改 schema 后重新生成绑定客户端类型与 Reducer 自动对齐是避免运行时错位的关键步骤。如果你希望在此基础上继续扩展仓库中还提供了sdk-test-*系列模块见 modules 目录与完整的 TS SDK 源码bindings-typescript/src可作为学习useTable之外更多 API如按条件订阅、事务、定时任务参数化的补充资料。【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考