关于 spacetimedb-typescript
介绍如何使用 SpacetimeDB 构建实时 TypeScript 客户端和模块,涵盖类型化数据访问、订阅、响应式更新、React 集成及常见 API 错误。
主要功能
- 01类型化数据库访问
- 02订阅与响应式更新
- 03React hooks 和 provider
- 04生成类型绑定
- 05模块 schema 与 reducer 指引
适用场景
- 01构建实时数据 TypeScript 客户端
- 02订阅数据库表
- 03将 SpacetimeDB 集成到 React
- 04排查 SDK 用法和模块错误
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ clockworklabs/ spacetimedb --skill spacetimedb-typescript安装后在 Claude Code 中输入/spacetimedb-typescript确认已加载。
详细介绍
SpacetimeDB TypeScript SDK
构建可直接连接 SpacetimeDB 模块的实时 TypeScript 客户端。该 SDK 为 Web 应用、Node.js、Deno、Bun 和其他 JavaScript 运行时提供类型安全的数据库访问、自动同步和响应式更新。
虚构的 API — 请勿使用
这些 API 并不存在。LLM 经常会虚构它们。
// WRONG PACKAGE — does not exist
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
// WRONG — these methods don't exist
SpacetimeDBClient.connect(...);
SpacetimeDBClient.call("reducer_name", [...]);
connection.call("reducer_name", [arg1, arg2]);
// WRONG — positional reducer arguments
conn.reducers.doSomething("value"); // WRONG!
// WRONG — old 1.0 patterns
spacetimedb.reducer('reducer_name', params, fn); // Use export const name = spacetimedb.reducer(params, fn)
schema(myTable); // Use schema({ myTable })
schema(t1, t2, t3); // Use schema({ t1, t2, t3 })
scheduled: 'run_cleanup' // Use scheduled: () => run_cleanup
.withModuleName('db') // Use .withDatabaseName('db') (2.0)
setReducerFlags.x('NoSuccessNotify') // Removed in 2.0
正确模式:
// CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
import { Identity } from 'spacetimedb';
// CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// CORRECT DATA ACCESS — useTable returns [rows, isReady]
const [items, isReady] = useTable(tables.item);
请勿:
- 虚构钩子,例如
useItems()、useData()— 请使用useTable(tables.tableName) - 从虚假软件包导入 — 仅使用
spacetimedb、spacetimedb/react、./module_bindings
常见错误表
服务端错误
| 错误做法 | 正确做法 | 错误 |
|---|---|---|
缺少 package.json | 创建 package.json | "could not detect language" |
缺少 tsconfig.json | 创建 tsconfig.json | "TsconfigNotFound" |
入口文件不在 src/index.ts | 使用 src/index.ts | 模块无法打包 |
在 COLUMNS(第 2 个参数)中设置 indexes | 在 table() 的 OPTIONS(第 1 个参数)中设置 indexes | "reading 'tag'" error |
索引缺少 algorithm | 使用 algorithm: 'btree' | "reading 'tag'" error |
filter({ ownerId }) | filter(ownerId) | "does not exist in type 'Range'" |
对唯一列使用 .filter() | 对唯一列使用 .find() | TypeError |
insert({ ...without id }) | insert({ id: 0n, ... }) | "Property 'id' is missing" |
const id = table.insert(...) | const row = table.insert(...) | .insert() 返回 ROW,而非 ID |
.unique() + 显式索引 | 仅使用 .unique() | "name is used for multiple entities" |
| 在 index.ts 中导入 spacetimedb | 从 schema.ts 导入 | "Cannot access before initialization" |
多列 .filter() 的范围形状不正确 | 与索引前缀/元组形状匹配 | 结果为空或范围/类型错误 |
在视图中使用 .iter() | 仅使用索引查找 | 视图无法扫描表 |
在过程函数中使用 ctx.db | 使用 ctx.withTx(tx => tx.db...) | 过程函数需要显式事务 |
客户端错误
| 错误做法 | 正确做法 | 错误 |
|---|---|---|
内联使用 connectionBuilder | 使用 useMemo(() => ..., []) | 每次渲染都会重新连接 |
const rows = useTable(table) | const [rows, isReady] = useTable(table) | 元组解构 |
| 乐观 UI 更新 | 让订阅驱动状态 | 同步问题 |
<SpacetimeDBProvider builder={...}> | connectionBuilder={...} | 属性名称错误 |
硬性要求
schema({ table })— 使用单个表对象;允许将可选模块设置作为第二个参数- Reducer/过程函数名称取自导出项 —
export const name = spacetimedb.reducer(params, fn);切勿使用reducer('name', ...) - Reducer 调用使用对象语法 — 使用
{ param: 'value' },而非位置参数 - 从
./module_bindings导入DbConnection— 而非从spacetimedb导入 - 请勿编辑生成的绑定 — 使用
spacetime generate重新生成 - 将索引放在 OPTIONS(第 1 个参数)中 — 而非
table()的 COLUMNS(第 2 个参数)中 - u64/i64 字段使用 BigInt — 使用
0n、1n,而非0、1 - Reducer 具有事务性 — 不会返回数据
- Reducer 必须具有确定性 — 不得使用文件系统、网络、计时器或随机数
- 视图应使用索引查找 —
.iter()会造成严重的性能问题 - 过程函数需要
ctx.withTx()— 过程函数中不存在ctx.db - 和类型值 — 使用
{ tag: 'variant', value: payload },而非{ variant: payload } - 使用
.withDatabaseName()— 不要使用.withModuleName()(2.0)
安装
npm install spacetimedb
对于不支持原生 fetch/WebSocket 的 Node.js 环境,请安装 undici。
生成类型绑定
spacetime generate --lang typescript --out-dir ./src/module_bindings --module-path ./server
客户端连接
import { DbConnection } from './module_bindings';
const connection = DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('my_database')
.withToken(localStorage.getItem('spacetimedb_token') ?? undefined)
.onConnect((conn, identity, token) => {
// identity: your unique Identity for this database
console.log('Connected as:', identity.toHexString());
// Save token for reconnection (preserves identity across sessions)
localStorage.setItem('spacetimedb_token', token);
conn.subscriptionBuilder()
.onApplied(() => console.log('Cache ready'))
.subscribe('SELECT * FROM player');
})
.onDisconnect((ctx) => console.log('Disconnected'))
.onConnectError((ctx, error) => console.error('Connection failed:', error))
.build();
订阅表
// Basic subscription
connection.subscriptionBuilder()
.onApplied((ctx) => console.log('Cache ready'))
.subscribe('SELECT * FROM player');
// Multiple queries
connection.subscriptionBuilder()
.subscribe(['SELECT * FROM player', 'SELECT * FROM game_state']);
// Subscribe to all tables (development only — cannot mix with Subscribe)
connection.subscriptionBuilder().subscribeToAllTables();
// Subscription handle for later unsubscribe
const handle = connection.subscriptionBuilder()
.onApplied(() => console.log('Subscribed'))
.subscribe('SELECT * FROM player');
handle.unsubscribeThen(() => console.log('Unsubscribed'));
访问表数据
for (const player of connection.db.player.iter()) { console.log(player.name); }
const players = Array.from(connection.db.player.iter());
const count = connection.db.player.count();
const player = connection.db.player.id.find(42n);
表事件回调
connection.db.player.onInsert((ctx, player) => console.log('New:', player.name));
connection.db.player.onDelete((ctx, player) => console.log('Left:', player.name));
connection.db.player.onUpdate((ctx, old, new_) => console.log(`${old.score} -> ${new_.score}`));
调用 Reducer
关键:使用对象语法,而非位置参数。
connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } });
snake_case 转 camelCase
- 服务端:
export const do_something = spacetimedb.reducer(...) - 客户端:
conn.reducers.doSomething({ ... })
身份和身份验证
identity和token在onConnect回调中提供(参见上文的客户端连接)- 使用
identity.toHexString()进行显示或记录日志 - 匿名连接时省略
.withToken()— 服务端会分配新身份 - 传入过期或无效的令牌:服务端会在
onConnect中签发新身份和令牌
错误处理
连接级别的错误(.onConnectError、.onDisconnect)请参见上文的客户端连接示例。
// Subscription error
connection.subscriptionBuilder()
.onApplied(() => console.log('Subscribed'))
.onError((ctx) => console.error('Subscription error:', ctx.event))
.subscribe('SELECT * FROM player');
服务端模块开发
表定义
import { schema, table, t } from 'spacetimedb/server';
export const Task = table({
name: 'task',
public: true,
indexes: [{ name: 'task_owner_id', algorithm: 'btree', columns: ['ownerId'] }]
}, {
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
title: t.string(),
createdAt: t.timestamp(),
});
列类型
t.identity() // User identity
t.u64() // Unsigned 64-bit integer (use for IDs)
t.string() // Text
t.bool() // Boolean
t.timestamp() // Timestamp
t.scheduleAt() // For scheduled tables only
t.object('Name', {}) // Product types (nested objects)
t.enum('Name', {}) // Sum types (tagged unions)
t.string().optional() // Nullable
BigInt 语法:所有
u64/i64字段都使用0n、1n,而不是0、1。
Schema 导出
const spacetimedb = schema({ Task, Player });
export default spacetimedb;
Reducer 定义(2.0)
名称来自导出 — 而不是字符串参数。
import spacetimedb from './schema';
import { t, SenderError } from 'spacetimedb/server';
export const create_task = spacetimedb.reducer(
{ title: t.string() },
(ctx, { title }) => {
if (!title) throw new SenderError('title required');
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title, createdAt: ctx.timestamp });
}
);
更新模式
const existing = ctx.db.task.id.find(taskId);
if (!existing) throw new SenderError('Task not found');
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
生命周期钩子
spacetimedb.clientConnected((ctx) => { /* ctx.sender is the connecting identity */ });
spacetimedb.clientDisconnected((ctx) => { /* clean up */ });
事件表(2.0)
2.0 中已移除 Reducer 回调。请改用事件表和 onInsert。
export const DamageEvent = table(
{ name: 'damage_event', public: true, event: true },
{ target: t.identity(), amount: t.u32() }
);
export const deal_damage = spacetimedb.reducer(
{ target: t.identity(), amount: t.u32() },
(ctx, { target, amount }) => {
ctx.db.damageEvent.insert({ target, amount });
}
);
客户端订阅并使用 onInsert:
conn.db.damageEvent.onInsert((ctx, evt) => {
playDamageAnimation(evt.target, evt.amount);
});
必须显式订阅事件表 — 它们不会包含在 subscribeToAllTables() 中。
视图
ViewContext 与 AnonymousViewContext
// ViewContext — has ctx.sender, result varies per user
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(Player.rowType), (ctx) => {
return ctx.from.player.where(p => p.score.gt(1000));
});
视图只能使用索引查找 — 不允许使用 .iter()。
计划任务表
export const CleanupJob = table({
name: 'cleanup_job',
scheduled: () => run_cleanup // function returning the exported reducer
}, {
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(),
});
export const run_cleanup = spacetimedb.reducer(
{ arg: CleanupJob.rowType },
(ctx, { arg }) => { /* arg.scheduledId, arg.targetId available */ }
);
// Schedule a job
import { ScheduleAt } from 'spacetimedb';
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + 60_000_000n),
targetId: someId
});
客户端上的 ScheduleAt
// ScheduleAt is a tagged union on the client
// { tag: 'Time', value: Timestamp } or { tag: 'Interval', value: TimeDuration }
const schedule = row.scheduledAt;
if (schedule.tag === 'Time') {
const date = new Date(Number(schedule.value.microsSinceUnixEpoch / 1000n));
}
时间戳
服务端
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n;
客户端
// Timestamps are objects with BigInt, not numbers
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
存储过程(Beta)
export const fetch_data = spacetimedb.procedure(
{ url: t.string() }, t.string(),
(ctx, { url }) => {
const response = ctx.http.fetch(url);
ctx.withTx(tx => { tx.db.myTable.insert({ id: 0n, content: response.text() }); });
return response.text();
}
);
存储过程没有 ctx.db — 请使用 ctx.withTx(tx => tx.db...)。
React 集成
import { useMemo } from 'react';
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
import { DbConnection, tables } from './module_bindings';
function Root() {
const connectionBuilder = useMemo(() =>
DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('my_game')
.withToken(localStorage.getItem('auth_token') || undefined)
.onConnect((conn, identity, token) => {
localStorage.setItem('auth_token', token);
conn.subscriptionBuilder().subscribe(tables.player);
}),
[]
);
return (
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
<App />
</SpacetimeDBProvider>
);
}
function PlayerList() {
const [players, isReady] = useTable(tables.player);
if (!isReady) return <div>Loading...</div>;
return <ul>{players.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
项目结构
服务端(backend/spacetimedb/)
src/schema.ts -> Tables, export spacetimedb
src/index.ts -> Reducers, lifecycle, import schema
package.json -> { "type": "module", "dependencies": { "spacetimedb": "^2.0.0" } }
tsconfig.json -> Standard config
客户端(client/)
src/module_bindings/ -> Generated (spacetime generate)
src/main.tsx -> Provider, connection setup
src/App.tsx -> UI components
命令
spacetime start
spacetime publish <module-name> --module-path <backend-dir>
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
spacetime logs <module-name>
评论
支持 Markdown