如何把应用后端从 Convex 迁移到 SpacetimeDB
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
这篇文章面向正在把应用后端从 Convex 迁到 SpacetimeDB 的开发者。两个系统都包含数据库状态、服务端逻辑、生成客户端和实时更新,但编程模型不同:Convex 中客户端通过query读数据、通过mutation写数据;SpacetimeDB 中客户端通过**订阅(subscription)**读取表的实时数据,通过reducer修改状态。迁移的核心就是围绕这个差异重写数据模型和服务端函数。按文档给出的流程走完,你可以得到一个发布到 Maincloud 或自托管宿主上的 SpacetimeDB 数据库,并让客户端通过生成的绑定完成连接、订阅与调用。
准备条件:安装 CLI、登录并创建项目
迁移工作开始前,按 Getting Started 完成三件事:
安装
spacetimeCLI。安装说明在 Getting Started 页面中,CLI 用于管理数据库与部署。登录 SpacetimeDB:
spacetime login命令会打开浏览器让你通过 GitHub 或 Google 登录。即使跳过这一步,后续需要登录的命令(如
spacetime publish)也会在运行时要求登录。创建 SpacetimeDB 项目。交互式入口是
spacetime dev,它会引导你输入项目名、选择项目路径和客户端类型,之后进入带热重载的开发模式(见 spacetime dev 文档)。如果你更喜欢手动创建,用:spacetime init --lang typescript --project-path ./my-project my-project cd my-project服务器语言可选 TypeScript、C#、Rust、C++,命令形式相同,只是
--lang取值不同。TypeScript 模板通常把模块代码放在spacetimedb/src/index.ts;文档标注spacetime dev目前是不稳定命令,后续可能变化。
如果需要本地跑一个独立服务器调试,可以在安装 CLI 后执行spacetime start。服务器默认监听 3000 端口,可通过--listen-addr修改;standalone 模式在前台运行,且不支持 SSL。
第一步:盘点 Convex 后端并给每个函数分类
在动代码之前,把 Convex 应用里的东西列全(见 Migrating from Convex):
convex/schema.ts中的全部表;- UI 组件用到的 query;
- 用户动作触发的 mutation;
- 用于第三方 API、邮件、支付、搜索等副作用的 action;
- 用于 webhook 或公开端点的 HTTP action;
- 定时函数与 cron 任务;
- 认证假设,尤其是用户 ID 字段和 provider 特定的 claims;
- File Storage 的使用情况;
- Components 与共享后端包。
然后按"每个函数实际做了什么"分类,这决定了它在 SpacetimeDB 中的去向:
| Convex 概念 | SpacetimeDB 去向 |
|---|---|
| Query | View 函数、订阅,或 SQL 查询 |
| Mutation | Reducer(事务性、确定性) |
| Action | Procedure(需要副作用时);只改数据库状态的应改为 reducer |
| HTTP Action | HTTP handler |
| 定时函数 / Cron | Schedule table |
| File Storage | 二进制列,或外部存储引用 |
| Components | 子模块,或独立模块/数据库 |
术语对照表中还有几个迁移时高频的概念:defineTable对应table(),defineSchema对应schema(...)或语言特定的模块 schema,文档 ID(Id<"table">)对应主键、唯一键或Identity,_id对应显式命名的主键列(常用id),_creationTime需要显式的Timestamp列并从ctx.timestamp赋值,useQuery对应"订阅加客户端缓存"或 view 订阅,useMutation对应生成的 reducer 调用,npx convex dev对应spacetime dev,npx convex deploy对应spacetime publish。
第二步:把文档式表重设计为关系行
Convex 的文档是 JSON 风格对象,SpacetimeDB 的表是带类型的关系行。文档明确建议不要机械地把每个嵌套文档塞进一张宽表,而是按访问模式拆表。例如 Convex 中这样定义的users文档:
users: defineTable({ name: v.string(), avatarUrl: v.optional(v.string()), preferences: v.object({ theme: v.string(), emailNotifications: v.boolean(), }), lastSeenAt: v.number(), });可以拆成 SpacetimeDB 的两张表:
import { schema, table, t } from 'spacetimedb/server'; const user = table( { name: 'user', public: true }, { identity: t.identity().primaryKey(), name: t.string(), avatarUrl: t.option(t.string()), lastSeenAt: t.timestamp().index('btree'), } ); const userPreference = table( { name: 'user_preference', public: true }, { identity: t.identity().primaryKey(), theme: t.string(), emailNotifications: t.bool(), } ); const spacetimeDb = schema({ user, userPreference }); export default spacetimeDb;文档给出的判断规则是:如果两个字段被读取或更新的频率不同,就考虑拆成不同的表——这能减少订阅带宽,保持热数据体积小。表的结构细节(列类型、约束、索引、可见性、调度)见 Tables。
第三步:把 mutation 改写成 reducer
Convex mutation 通常变成 SpacetimeDB reducer:把校验、授权和写库都放进 reducer。文档用"发送消息"这个场景做了对照。Convex 侧:
export const send = mutation({ args: { channelId: v.id('channels'), body: v.string() }, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (identity === null) throw new Error('Not signed in'); return await ctx.db.insert('messages', { channelId: args.channelId, author: identity.subject, body: args.body, createdAt: Date.now(), }); }, });对应的 SpacetimeDB 写法:
import { schema, table, t, SenderError } from 'spacetimedb/server'; const message = table( { name: 'message', public: true }, { id: t.u64().primaryKey().autoInc(), channelId: t.u64().index('btree'), author: t.identity().index('btree'), body: t.string(), createdAt: t.timestamp().index('btree'), } ); const spacetimeDb = schema({ message }); export default spacetimeDb; export const sendMessage = spacetimeDb.reducer( { channelId: t.u64(), body: t.string() }, (ctx, { channelId, body }) => { if (body.trim() === '') { throw new SenderError('Message body cannot be empty'); } ctx.db.message.insert({ id: 0n, channelId, author: ctx.sender, body, createdAt: ctx.timestamp, }); } );改写时注意两个差异:
- reducer 用
ctx.sender作为已认证调用者,不要接受客户端传入的用户身份参数; - reducer 不返回插入的行 ID。客户端通过订阅
message表感知新行。
如果客户端需要一次性的成功/失败通知,用 SDK 的按调用 reducer 结果回调;如果其他订阅者需要临时事件,在 reducer 里向事件表(event table)插入一行。Reducer 的完整定义见 Reducers。
第四步:把 query 替换为订阅和 view
Convex query 通常混着两种用途:为 UI 取实时行,以及从一张或多张表算出服务端结果。SpacetimeDB 里分开处理:
- 取实时行:客户端直接订阅表或 SQL 查询,从客户端缓存渲染。例如 Convex 中按 channel 取最新 100 条消息的 query,可以改成订阅
SELECT * FROM message WHERE channelId = ... ORDER BY createdAt DESC LIMIT 100,前提是表上有支持该查询形状的channelId和createdAt索引。 - 算计算型读模型:定义 view。view 尤其适合 join 和派生行:
import type { Timestamp } from 'spacetimedb'; const messageWithAuthor = t.row('MessageWithAuthor', { id: t.u64(), channelId: t.u64(), authorName: t.string(), body: t.string(), createdAt: t.timestamp(), }); export const messagesWithAuthors = spacetimeDb.anonymousView( { name: 'messages_with_authors', public: true }, t.array(messageWithAuthor), ctx => { const rows: Array<{ id: bigint; channelId: bigint; authorName: string; body: string; createdAt: Timestamp; }> = []; for (const msg of ctx.db.message.iter()) { const author = ctx.db.user.identity.find(msg.author); if (author) { rows.push({ id: msg.id, channelId: msg.channelId, authorName: author.name, body: msg.body, createdAt: msg.createdAt, }); } } return rows; } );有一个明确限制:view 目前不接受任意客户端参数。如果一个 Convex query 带参数,文档给出三条路:客户端用参数化的 SQL/query-builder 表达式订阅、把参数建模为订阅表数据的一部分,或者让 view 的结果可以由客户端订阅侧过滤。订阅机制见 Subscriptions,view 见 Views。
第五步:把 action 拆成 reducer 与 procedure
Convex action 既调第三方服务,也能调 query/mutation;SpacetimeDB 里要把确定性的数据库变更留在 reducer,把副作用工作移到 procedure。文档给出的工作流模式(例如支付处理):
- 一个 reducer 记录请求的操作并校验调用者;
- 一个 procedure 执行外部 API 调用;
- procedure 用
withTx提交结果对应的数据库变更,或者如果该操作可以表达为常规状态转换,就调用一个 reducer。
约束是:reducer 里不做网络工作,因为 reducer 必须是确定性和事务性的。需要 procedure 能力的场景(出站 HTTP 等)见 Procedures。
Convex 的 HTTP action 则对应 SpacetimeDB 的 HTTP handler,用于 webhook、OAuth 回调、上传回调和公开 HTTP API。如果调用方是 SpacetimeDB 客户端、需要副作用但不需要 HTTP 路由,用 procedure。
第六步:迁移认证、定时任务、文件与共享组件
认证与用户。Convex 的ctx.auth.getUserIdentity()对应 SpacetimeDB 函数上下文中的调用者Identity(ctx.sender)。把用户行以Identity作为键存储:
const user = table( { name: 'user', public: true }, { identity: t.identity().primaryKey(), displayName: t.string(), createdAt: t.timestamp(), } ); export const createProfile = spacetimeDb.reducer( { displayName: t.string() }, (ctx, { displayName }) => { ctx.db.user.insert({ identity: ctx.sender, displayName, createdAt: ctx.timestamp, }); } );需要 provider 特定数据时,检查认证上下文里可用的 OIDC claims(SpacetimeDB 支持包括 SpacetimeAuth、Auth0、Clerk 在内的 OIDC provider)。授权检查应在 reducer、view、procedure 和连接生命周期 reducer 中基于ctx.sender和 claims 完成,详见 Authentication。
定时任务。Convex 的定时函数和 cron 对应 schedule table:表中插入的行会让某个 reducer 或 procedure 在特定时间或按间隔运行。确定性的数据库维护用 scheduled reducer;需要外部 I/O 的任务(发邮件、调第三方 API)用 scheduled procedure。
文件。Convex File Storage 对应两种模式:小型二进制数据直接放进表列(需要参与事务、随行实时更新时);大文件放对象存储,SpacetimeDB 表里保留元数据、归属和 URL。浏览器上传的常见流程:客户端走既有上传流程把文件传到对象存储 → 客户端调用 reducer 登记元数据和归属 → 其他客户端通过订阅收到元数据。
Components 与共享后端代码。SpacetimeDB 侧要显式建模边界:可用子模块放可复用的隔离系统,需要运维隔离时用独立模块/数据库,纯逻辑留在普通语言模块或包里,集成边界通过 procedure、HTTP handler 和收窄的表 schema 表达。不要把共享代码直接放开到无关表的访问权限——保留 Convex 组件当初的接口边界。
验证与发布:生成绑定、更新客户端并 publish
迁移完成后的落地路径(对应迁移文档的 Checklist):
用
spacetime generate或spacetime dev生成客户端绑定。spacetime dev会启动本地服务器、创建数据库、构建并发布模块,监听源码变化并在保存时自动重建重发布;如果配置了客户端开发命令(写在项目根目录spacetime.json的dev.run字段,例如"run": "npm run dev"),它还会顺带跑客户端开发服务器。该命令在含spacetimedb/目录的已有项目中会跳过初始化直接进入开发模式。更新客户端:连接数据库、订阅表或 view、从客户端缓存渲染、调用生成的 reducer/procedure 方法。
发布。在模块目录(通常是
spacetimedb/)执行:spacetime login spacetime publish <DATABASE_NAME>spacetime publish会自动构建模块(无需单独执行spacetime build,但spacetime build可以单独用来编译并校验模块结构),创建数据库、上传安装模块、运行init生命周期 reducer(如果定义了)并开始接受客户端连接。发布成功后 SpacetimeDB 会输出数据库的 identity,务必保存,后续管理操作要用它。
两个有破坏性的选项要特别留意(见 spacetime publish):
spacetime publish --break-clients <DATABASE_NAME>:用于无法自动迁移的破坏性 schema 变更,会打断未适配新 schema 的现有客户端;spacetime publish <DATABASE_NAME> --delete-data:重置数据库并永久删除全部数据,迁移已上线应用时不要误用。
完整命令参数见 CLI reference。
常见迁移陷阱
迁移文档总结了五个高频问题,都在上文流程中有对应动作:
- 期望 reducer 返回数据:reducer 只做事务性状态变更。UI 需要的数据要建模成行并订阅;临时消息用事件表;必须请求/响应式的流程用 procedure。
- 文档照搬成宽行:直接的文档到行转换会产生更新过于频繁的大行,制造不必要的订阅流量。按访问模式和更新频率拆表。
- 从客户端传用户 ID 做授权:不要信任客户端传来的用户 ID 参数,用上下文中的调用者
Identity再查用户行。 - 用 procedure 做常规写:reducer 是默认写路径,除非需要出站 HTTP 等 procedure 专属能力。
- 忘记索引:Convex 的
withIndex(...)让索引使用显式可见,SpacetimeDB 需要同样的设计步骤——为你的应用依赖的查找和订阅定义索引。
迁移完成判断
迁移完成的标志就是迁移文档 Checklist 全部打勾:表已按访问模式定义、文档 ID 换成显式主键/唯一键/Identity列、时间戳列显式化、索引齐备、mutation 全部转为 reducer(返回值换成订阅/事件表/view/procedure 返回)、query 转为订阅或 view、副作用 action 转为 procedure、HTTP action 转为 HTTP handler、定时任务进入 schedule table、授权改为ctx.sender与 OIDC claims 检查、客户端绑定已生成、客户端已改为"连接、订阅、缓存渲染、调用生成的 reducer/procedure",最后spacetime publish成功并拿到数据库 identity。此后如需继续深入,按术语表中的对应项分别阅读 Tables、Reducers、Views、Procedures、HTTP handlers 和 Subscriptions。
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考