typescript-server
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSpacetimeDB TypeScript SDK Reference
SpacetimeDB TypeScript SDK 参考
Module Structure
模块结构
Tables are built with , bound with , and exported as default. Reducers and lifecycle hooks are :
table()schema()export consttypescript
import { schema, table, t } from 'spacetimedb/server';
const scoreRecord = table(
{ name: 'score_record', public: true },
{
id: t.u64().primaryKey().autoInc(),
owner: t.identity(),
value: t.u32(),
}
);
const spacetimedb = schema({ scoreRecord }); // ONE object, not spread args
export default spacetimedb;
export const addRecord = spacetimedb.reducer(
{ value: t.u32() },
(ctx, { value }) => {
ctx.db.scoreRecord.insert({ id: 0n, owner: ctx.sender, value });
}
);schema({...})schema(tables, { env: { ... } })Export the default schema and every reducer, lifecycle hook, view, procedure, HTTP export, and visibility filter to register. Root-entry named exports must be registered SpacetimeDB values: keep ordinary root helpers/constants unexported. A submodule library may export ordinary helpers for its consumer to call, because submodule discovery ignores non-module exports; do not re-export those helpers from the root entry.
表使用 构建、通过 绑定,并作为默认导出。Reducer 和生命周期钩子使用 :
table()schema()export consttypescript
import { schema, table, t } from 'spacetimedb/server';
const scoreRecord = table(
{ name: 'score_record', public: true },
{
id: t.u64().primaryKey().autoInc(),
owner: t.identity(),
value: t.u32(),
}
);
const spacetimedb = schema({ scoreRecord }); // ONE object, not spread args
export default spacetimedb;
export const addRecord = spacetimedb.reducer(
{ value: t.u32() },
(ctx, { value }) => {
ctx.db.scoreRecord.insert({ id: 0n, owner: ctx.sender, value });
}
);schema({...})schema(tables, { env: { ... } })需要注册时,请导出默认 schema,以及每个 Reducer、生命周期钩子、View、Procedure、HTTP export 和可见性过滤器。根入口的命名导出必须是已注册的 SpacetimeDB 值:普通根级辅助函数和常量不要导出。子模块库可以导出普通辅助函数供使用方调用,因为子模块发现会忽略非模块导出;不要从根入口重新导出这些辅助函数。
Imports
导入
Schema builders and module exports come from . Runtime value classes such as , , and come from the root package; comes from :
spacetimedb/serverScheduleAtTimestampConnectionIdspacetimedbRangespacetimedb/servertypescript
import {
schema, table, t, SenderError,
type InferSchema, type ReducerCtx,
} from 'spacetimedb/server';
import { ConnectionId, ScheduleAt, Timestamp } from 'spacetimedb';
import { Range } from 'spacetimedb/server';Schema 构建器和模块导出来自 。、 和 等运行时值类来自根包 ; 来自 :
spacetimedb/serverScheduleAtTimestampConnectionIdspacetimedbRangespacetimedb/servertypescript
import {
schema, table, t, SenderError,
type InferSchema, type ReducerCtx,
} from 'spacetimedb/server';
import { ConnectionId, ScheduleAt, Timestamp } from 'spacetimedb';
import { Range } from 'spacetimedb/server';Tables
表
table(OPTIONS, COLUMNS)nametypescript
const entity = table(
{ name: 'entity', public: true },
{
identity: t.identity().primaryKey(),
name: t.string(),
active: t.bool(),
}
);Options: (snake_case, recommended), , ,
namepublic: trueevent: trueindexes: [...]ctx.dbschema({...})schema({ scoreRecord })ctx.db.scoreRecordname: 'snake_case'table(OPTIONS, COLUMNS)nametypescript
const entity = table(
{ name: 'entity', public: true },
{
identity: t.identity().primaryKey(),
name: t.string(),
active: t.bool(),
}
);选项:(snake_case,推荐)、、、
namepublic: trueevent: trueindexes: [...]ctx.dbschema({...})schema({ scoreRecord })ctx.db.scoreRecordname: 'snake_case'Column Types
列类型
Every column is a builder value:
t| Builder | JS type | Notes |
|---|---|---|
| number | |
| number | |
| bigint | Use |
| bigint | Use |
| bigint | |
| number | |
| boolean | |
| string | |
| Identity | |
| ConnectionId | |
| Timestamp | |
| TimeDuration | |
| ScheduleAt |
Modifiers: , , , , .
.primaryKey().autoInc().unique().index('btree').default(value)Use only for a newly appended migration-safe field. Do not put defaults on primary-key, unique, or auto-increment columns.
.default(value)Optional columns:
nickname: t.option(t.string())Schema builders describe the database's wire types; they are not TypeScript type names. For example, a value is a TypeScript , not a value cast to a type named .
t.u16()numberu16每一列都是一个 构建器值:
t| 构建器 | JS 类型 | 说明 |
|---|---|---|
| number | |
| number | |
| bigint | 使用 |
| bigint | 使用 |
| bigint | |
| number | |
| boolean | |
| string | |
| Identity | |
| ConnectionId | |
| Timestamp | |
| TimeDuration | |
| ScheduleAt |
修饰符:、、、、。
.primaryKey().autoInc().unique().index('btree').default(value)仅可对 newly appended 且迁移安全的字段使用 。不要在主键、唯一列或自增列上设置默认值。
.default(value)可选列:
nickname: t.option(t.string())Schema 构建器描述的是数据库的传输类型,而不是 TypeScript 类型名。例如, 值是 TypeScript ,而不是转换为名为 的类型。
t.u16()numberu16Indexes
索引
Use inline when a single-column index does not need a named accessor. Use an entry when the accessor is named explicitly or the index spans multiple columns. Every entry requires ; do not also add to the same column.
.index('btree')indexesindexescolumns.index('btree')typescript
// Inline (preferred for single-column):
authorId: t.u64().index('btree'),
// Access: ctx.db.post.authorId.filter(authorId);
// Multi-column (named):
indexes: [{ accessor: 'byGroupUser', algorithm: 'btree', columns: ['groupId', 'userId'] }]
// Access: ctx.db.membership.byGroupUser.filter([groupId, userId]);Prefer a multi-column index over filtering by one column and looping. Filter takes an array in index column order; a prefix scan passes the leading value bare: .
filter(groupId)The published module's entry file must export the schema as default. If you split tables
() from reducers/lifecycle (), re-export it from the entry:
schema.tsindex.tstypescript
// index.ts
export { default } from './schema'; // re-export the schema for the module entry当单列索引不需要命名访问器时,使用内联 。需要显式命名访问器或索引涵盖多个列时,使用 条目。每个 条目都必须指定 ;不要对同一列再添加 。
.index('btree')indexesindexescolumns.index('btree')typescript
// Inline (preferred for single-column):
authorId: t.u64().index('btree'),
// Access: ctx.db.post.authorId.filter(authorId);
// Multi-column (named):
indexes: [{ accessor: 'byGroupUser', algorithm: 'btree', columns: ['groupId', 'userId'] }]
// Access: ctx.db.membership.byGroupUser.filter([groupId, userId]);相比按单个列过滤后再循环,应优先使用多列索引。Filter 按索引列顺序接收数组;前缀扫描则直接传入最前面的值:。
filter(groupId)已发布模块的入口文件必须将 schema 作为默认导出。如果将表()与 Reducer/生命周期钩子()拆分,请从入口文件重新导出 schema:
schema.tsindex.tstypescript
// index.ts
export { default } from './schema'; // re-export the schema for the module entryReducers
Reducer
Reducers are created with ; the export name becomes the reducer name:
spacetimedb.reducer(...)typescript
export const createEntity = spacetimedb.reducer(
{ name: t.string(), age: t.i32() },
(ctx, { name, age }) => {
ctx.db.entity.insert({ identity: ctx.sender, name, age, active: true });
}
);
// No arguments, just the callback:
export const doReset = spacetimedb.reducer((ctx) => { ... });Reducer args accept any column type, including arrays of custom types: . Do not pass JSON strings for structured data.
{ splits: t.array(Split) }Reducer 使用 创建;导出名称会成为 Reducer 名称:
spacetimedb.reducer(...)typescript
export const createEntity = spacetimedb.reducer(
{ name: t.string(), age: t.i32() },
(ctx, { name, age }) => {
ctx.db.entity.insert({ identity: ctx.sender, name, age, active: true });
}
);
// No arguments, just the callback:
export const doReset = spacetimedb.reducer((ctx) => { ... });Reducer 参数接受任意列类型,包括自定义类型的数组:。不要为结构化数据传入 JSON 字符串。
{ splits: t.array(Split) }DB Operations
数据库操作
typescript
ctx.db.scoreRecord.insert({ id: 0n, owner: ctx.sender, value: 1 }); // Insert (0n for autoInc)
ctx.db.scoreRecord.id.find(recordId); // Find by PK → row | null
ctx.db.entity.identity.find(ctx.sender); // Find by unique column
[...ctx.db.post.authorId.filter(authorId)]; // Filter → spread to Array
[...ctx.db.entity.iter()]; // All rows → Array
ctx.db.scoreRecord.id.update({ ...existing, value: 2 }); // Update (spread + override)
ctx.db.scoreRecord.id.delete(recordId); // Delete by PKInsert through the table accessor (). Primary-key, unique, and index accessors support lookup or mutation of existing rows, but do not have .
ctx.db.scoreRecord.insert(...)insert(...)insert(...)The accessor for a primary key or index is the declared column name. For example, a primary key named is accessed as , not .
eventIdctx.db.event.eventIdctx.db.event.idThe schema value registers module exports but does not expose database rows. Pass a context into any helper that needs .
ctx.dbNote: and return iterators. Spread to Array for , , .
iter()filter().sort().filter().map()typescript
ctx.db.scoreRecord.insert({ id: 0n, owner: ctx.sender, value: 1 }); // Insert (0n for autoInc)
ctx.db.scoreRecord.id.find(recordId); // Find by PK → row | null
ctx.db.entity.identity.find(ctx.sender); // Find by unique column
[...ctx.db.post.authorId.filter(authorId)]; // Filter → spread to Array
[...ctx.db.entity.iter()]; // All rows → Array
ctx.db.scoreRecord.id.update({ ...existing, value: 2 }); // Update (spread + override)
ctx.db.scoreRecord.id.delete(recordId); // Delete by PK通过表访问器执行插入()。主键、唯一列和索引访问器支持查找或修改已有行,但不提供 。
ctx.db.scoreRecord.insert(...)insert(...)insert(...)主键或索引的访问器就是声明的列名。例如,名为 的主键应通过 访问,而不是 。
eventIdctx.db.event.eventIdctx.db.event.idSchema 值用于注册模块导出,但不会公开数据库行。需要访问 的辅助函数应接收上下文参数。
ctx.db请注意, 和 返回迭代器。要使用 、、,请先展开为 Array。
iter()filter().sort().filter().map()Lifecycle Hooks
生命周期钩子
MUST be . Bare calls are silently ignored:
export consttypescript
export const init = spacetimedb.init((ctx) => { ... });
export const onConnect = spacetimedb.clientConnected((ctx) => { ... });
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { ... });ctx.connectionIdConnectionId | null必须使用 。裸调用会被静默忽略:
export consttypescript
export const init = spacetimedb.init((ctx) => { ... });
export const onConnect = spacetimedb.clientConnected((ctx) => { ... });
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { ... });即使在生命周期上下文中, 也是 。将其传给辅助函数或用作表键之前,请先进行空值检查。
ctx.connectionIdConnectionId | nullReducer Context API
Reducer 上下文 API
ctxReducerCtx<InferSchema<typeof spacetimedb>>anybigintnumbertypescript
type Ctx = ReducerCtx<InferSchema<typeof spacetimedb>>;
function findRecord(ctx: Ctx, id: bigint) {
return ctx.db.scoreRecord.id.find(id);
}typescript
// Auth: ctx.sender is the caller's Identity
if (!row.owner.equals(ctx.sender)) throw new SenderError('unauthorized');
// ctx.connectionId: the per-connection id, NULLABLE (ConnectionId | null) — null-check before use.
// One Identity can hold several connections (multiple tabs/devices).
if (ctx.connectionId) { /* ... */ }
// Server timestamp (deterministic per reducer call)
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Deterministic RNG
const f: number = ctx.random(); // [0.0, 1.0)
const roll: number = ctx.random.integerInRange(1, 6); // safe JS number bounds/result, inclusive
const storedRoll: bigint = BigInt(roll); // convert the result for an i64/u64 column
const bytes: Uint8Array = ctx.random.fill(new Uint8Array(16));
// 2.13+: in-place shuffle; copy first to preserve the original array.
const shuffled = ctx.random.shuffle([...cards]);
// Client: Timestamp → Date
new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));Do not construct values from strings (e.g. ): serialization fails and kills the module. Identities come from or columns.
Identity'hex' as Identityctx.sendert.identity()Construct a from its numeric representation with after importing from .
ConnectionIdnew ConnectionId(value)ConnectionIdspacetimedbConstruct exact timestamps with after importing from . Inclusive index ranges use from :
new Timestamp(micros)TimestampspacetimedbRangespacetimedb/servertypescript
import { Timestamp } from 'spacetimedb';
import { Range } from 'spacetimedb/server';
ctx.db.shipment.deliverBy.filter(new Range(
{ tag: 'included', value: new Timestamp(1_000n) },
{ tag: 'included', value: new Timestamp(2_000n) },
));ctxReducerCtx<InferSchema<typeof spacetimedb>>anybigintnumbertypescript
type Ctx = ReducerCtx<InferSchema<typeof spacetimedb>>;
function findRecord(ctx: Ctx, id: bigint) {
return ctx.db.scoreRecord.id.find(id);
}typescript
// Auth: ctx.sender is the caller's Identity
if (!row.owner.equals(ctx.sender)) throw new SenderError('unauthorized');
// ctx.connectionId: the per-connection id, NULLABLE (ConnectionId | null) — null-check before use.
// One Identity can hold several connections (multiple tabs/devices).
if (ctx.connectionId) { /* ... */ }
// Server timestamp (deterministic per reducer call)
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
// Deterministic RNG
const f: number = ctx.random(); // [0.0, 1.0)
const roll: number = ctx.random.integerInRange(1, 6); // safe JS number bounds/result, inclusive
const storedRoll: bigint = BigInt(roll); // convert the result for an i64/u64 column
const bytes: Uint8Array = ctx.random.fill(new Uint8Array(16));
// 2.13+: in-place shuffle; copy first to preserve the original array.
const shuffled = ctx.random.shuffle([...cards]);
// Client: Timestamp → Date
new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));不要通过字符串构造 值(例如 ):序列化会失败并导致模块终止。Identity 来自 或 列。
Identity'hex' as Identityctx.sendert.identity()从 导入 后,可通过其数值表示构造连接 ID:。
spacetimedbConnectionIdnew ConnectionId(value)从 导入 后,可通过微秒值构造精确时间戳:。包含边界值的索引范围使用来自 的 :
spacetimedbTimestampnew Timestamp(micros)spacetimedb/serverRangetypescript
import { Timestamp } from 'spacetimedb';
import { Range } from 'spacetimedb/server';
ctx.db.shipment.deliverBy.filter(new Range(
{ tag: 'included', value: new Timestamp(1_000n) },
{ tag: 'included', value: new Timestamp(2_000n) },
));Scheduled Tables
计划任务表
The reducer or procedure referenced by a table's option must be exported.
scheduledtypescript
import { ScheduleAt } from 'spacetimedb'; // ScheduleAt comes from the root package
const tickTimer = table({
name: 'tick_timer',
}, {
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
});
export const tick = spacetimedb.reducer(
{ onSchedule: tickTimer },
{ timer: tickTimer.rowType },
(ctx, { timer }) => { /* timer row auto-deleted after this runs */ }
);
// `onSchedule` also works for scheduled procedures whose return type is `t.unit()`.
// Legacy table-side scheduling, `scheduled: (): any => tick`, still works but is not
// recommended for new code because it forces a forward reference.
// One-time: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + delayMicros)
// Repeating: ScheduleAt.interval(60_000_000n)
// Read time back from a scheduleAt value (tagged union):
// const micros = at.tag === 'time' ? at.value : at.value.microsSinceUnixEpoch; // bigint表的 选项所引用的 Reducer 或 Procedure 必须导出。
scheduledtypescript
import { ScheduleAt } from 'spacetimedb'; // ScheduleAt comes from the root package
const tickTimer = table({
name: 'tick_timer',
}, {
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
});
export const tick = spacetimedb.reducer(
{ onSchedule: tickTimer },
{ timer: tickTimer.rowType },
(ctx, { timer }) => { /* timer row auto-deleted after this runs */ }
);
// `onSchedule` also works for scheduled procedures whose return type is `t.unit()`.
// Legacy table-side scheduling, `scheduled: (): any => tick`, still works but is not
// recommended for new code because it forces a forward reference.
// One-time: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + delayMicros)
// Repeating: ScheduleAt.interval(60_000_000n)
// Read time back from a scheduleAt value (tagged union):
// const micros = at.tag === 'time' ? at.value : at.value.microsSinceUnixEpoch; // bigintSubmodules
子模块
Import the library's full module-namespace object, not just its default schema:
typescript
import * as authLib from 'auth_lib';
const spacetimedb = schema({
scoreRecord,
myAuth: { name: 'auth_data', module: authLib },
});
export default spacetimedb;myAuthctx.db.myAuth.<table>auth_datanamemyAuthmy_authnamectx.dbctx.as.myAuthwithTxtx.as.myAuthOfficial reusable libraries include , , , and . Their registration shapes differ: is mountable, cron/retry expose factories, and crypto exports helpers. Consult the chosen package's README/exports rather than assuming every package root is a mountable module.
@spacetimedb/cron@spacetimedb/retry@spacetimedb/rate-limit@spacetimedb/crypto@spacetimedb/rate-limit/submodule请导入库的完整模块命名空间对象,而不只是其默认 schema:
typescript
import * as authLib from 'auth_lib';
const spacetimedb = schema({
scoreRecord,
myAuth: { name: 'auth_data', module: authLib },
});
export default spacetimedb;myAuthctx.db.myAuth.<table>auth_datanamemyAuthmy_authnamectx.dbctx.as.myAuthwithTxtx.as.myAuth官方可复用库包括 、、 和 。它们的注册形式各不相同: 可以挂载,cron/retry 提供工厂,crypto 导出辅助函数。请查阅所选包的 README/exports,不要假定每个包根模块都可挂载。
@spacetimedb/cron@spacetimedb/retry@spacetimedb/rate-limit@spacetimedb/crypto@spacetimedb/rate-limit/submoduleEnvironment Variables
环境变量
typescript
const spacetimedb = schema({ scoreRecord }, {
env: {
API_KEY: t.string(),
MODE: t.enum('Mode', ['development', 'production']),
LOG_LEVEL: t.string().optional(),
},
});
// In a root reducer/procedure/view/handler:
// ctx.env.API_KEY: string; ctx.env.LOG_LEVEL: string | undefined
// ctx.env.get('LOG_LEVEL'): string | nullSee the concepts skill for shared environment and secret-handling rules.
typescript
const spacetimedb = schema({ scoreRecord }, {
env: {
API_KEY: t.string(),
MODE: t.enum('Mode', ['development', 'production']),
LOG_LEVEL: t.string().optional(),
},
});
// In a root reducer/procedure/view/handler:
// ctx.env.API_KEY: string; ctx.env.LOG_LEVEL: string | undefined
// ctx.env.get('LOG_LEVEL'): string | null有关共享的环境变量和 secrets 处理规则,请参阅 concepts skill。
Inter-Database Calls (Async IDC)
数据库间调用(异步 IDC)
A reducer calls a reducer in another database by inserting a row into an outbox table. The call is sent only if the transaction commits, runs exactly once, and runs in order with other calls through the same outbox to the same database. The sending reducer doesn't wait for it. Both databases must be on the same server.
Generate receiver bindings by listing the receiving module under in the sending database's , then running . TypeScript bindings are written to .
dependenciesspacetime.jsonspacetime generate <sending-database>src/remote_bindings/json
{
"database": "lobby",
"module-path": "./lobby",
"dependencies": [{ "name": "game_world", "database": "game-world" }],
"children": [{ "database": "game-world", "module-path": "./game_world" }]
}typescript
import gw from './remote_bindings/game_world';
// Each row is one call to game_world's `admitPlayer({ name })` reducer.
const admitPlayerOutbox = table(
{ outbox: { receiver: gw.reducers.admitPlayer } },
{
msgId: t.u64().primaryKey().autoInc(), // exactly one u64 autoInc primary key
world: gw.identity().target(), // exactly one target, typed by the bindings
name: t.string(), // one column per reducer parameter, same name and type
// playerName: t.string().param({ name: 'name' }), // alias a column to a parameter
// Extra columns are allowed. They aren't sent, but onResult can read them.
}
);
// Shorthand: gw.outbox.admitPlayer() declares { msgId, target, ...params } for you.
// It can't be used for a reducer with a parameter named msgId or target.
const spacetimedb = schema({ admitPlayerOutbox });
export default spacetimedb;
export const sendToWorld = spacetimedb.reducer(
{ name: t.string(), world: gw.identity() },
(ctx, { name, world }) => {
// Sent if and only if this transaction commits.
ctx.db.admitPlayerOutbox.insert({ msgId: 0n, world, name });
}
);
// Optional, at most one per outbox. Private. Runs once per row, then the row is deleted.
export const onAdmitPlayerResult = spacetimedb.reducer(
{ onResult: admitPlayerOutbox },
{ row: admitPlayerOutbox.rowType, result: t.result(t.unit(), t.string()) },
(ctx, { row, result }) => {
if ('err' in result) console.warn(`${row.name} was not admitted: ${result.err}`);
}
);- The receiving reducer is an ordinary reducer and needs no changes. In it, is the sending database's identity, so authorize calls by checking it against an allow-list table.
ctx.sender - The receiving database runs and
clientConnectedaround every delivered call, with no JWT. IfclientDisconnectedrejects the sending database, the call is retried forever.clientConnected - Outbox rows are insert-only, so a pending call can't be cancelled. A call is never retried once it has a result. To try again, insert a new row, for example from .
onResult - A plain can't be a target. Use values typed
Identity, such as reducer arguments or columns of that type. Index lookups on those columns accept a plaingw.identity().Identity - An outbox table can't also be a scheduled table or an event table.
- After the receiving reducer's parameters change, regenerate the bindings and republish the sending database. Calls built from old bindings fail with a signature mismatch error.
See https://spacetimedb.com/docs/databases/inter-database-communication for details.
Reducer 可以通过向 outbox 表插入一行来调用另一个数据库中的 Reducer。仅当事务提交时调用才会发送;调用恰好执行一次,并与通过同一 outbox 发送到同一数据库的其他调用按顺序执行。发送方 Reducer 不会等待它完成。两个数据库必须位于同一服务器上。
要生成接收方绑定,请先在发送方数据库的 中通过 列出接收模块,然后运行 。TypeScript 绑定将写入 。
spacetime.jsondependenciesspacetime generate <sending-database>src/remote_bindings/json
{
"database": "lobby",
"module-path": "./lobby",
"dependencies": [{ "name": "game_world", "database": "game-world" }],
"children": [{ "database": "game-world", "module-path": "./game_world" }]
}typescript
import gw from './remote_bindings/game_world';
// Each row is one call to game_world's `admitPlayer({ name })` reducer.
const admitPlayerOutbox = table(
{ outbox: { receiver: gw.reducers.admitPlayer } },
{
msgId: t.u64().primaryKey().autoInc(), // exactly one u64 autoInc primary key
world: gw.identity().target(), // exactly one target, typed by the bindings
name: t.string(), // one column per reducer parameter, same name and type
// playerName: t.string().param({ name: 'name' }), // alias a column to a parameter
// Extra columns are allowed. They aren't sent, but onResult can read them.
}
);
// Shorthand: gw.outbox.admitPlayer() declares { msgId, target, ...params } for you.
// It can't be used for a reducer with a parameter named msgId or target.
const spacetimedb = schema({ admitPlayerOutbox });
export default spacetimedb;
export const sendToWorld = spacetimedb.reducer(
{ name: t.string(), world: gw.identity() },
(ctx, { name, world }) => {
// Sent if and only if this transaction commits.
ctx.db.admitPlayerOutbox.insert({ msgId: 0n, world, name });
}
);
// Optional, at most one per outbox. Private. Runs once per row, then the row is deleted.
export const onAdmitPlayerResult = spacetimedb.reducer(
{ onResult: admitPlayerOutbox },
{ row: admitPlayerOutbox.rowType, result: t.result(t.unit(), t.string()) },
(ctx, { row, result }) => {
if ('err' in result) console.warn(`${row.name} was not admitted: ${result.err}`);
}
);- 接收方 Reducer 是普通 Reducer,无需修改。在其中,是发送方数据库的 Identity,因此应通过白名单表检查调用方权限。
ctx.sender - 每次传递调用时,接收方数据库都会在调用前后运行 和
clientConnected,且不使用 JWT。如果clientDisconnected拒绝发送方数据库,该调用将被无限重试。clientConnected - Outbox 行仅支持插入,因此无法取消待处理调用。调用一旦产生结果就不会重试。要再次尝试,请插入新行,例如在 中插入。
onResult - 普通 不能作为目标。请使用由
Identity类型化的值,例如 Reducer 参数或该类型的列。对这些列执行索引查找时,可以接受普通gw.identity()。Identity - Outbox 表不能同时是计划任务表或事件表。
- 接收方 Reducer 的参数发生变化后,请重新生成绑定并重新发布发送方数据库。使用旧绑定构建的调用会因签名不匹配而失败。
Custom Types
自定义类型
typescript
// Product type (struct):
const Position = t.object('Position', { x: t.i32(), y: t.i32() });
const entity = table({ name: 'entity' }, {
id: t.u64().primaryKey().autoInc(),
pos: Position,
});
// Sum type (tagged union):
const Shape = t.enum('Shape', {
circle: t.i32(),
rectangle: t.object('Rect', { w: t.i32(), h: t.i32() }),
});
// Values: { tag: 'circle', value: 10 }typescript
// Product type (struct):
const Position = t.object('Position', { x: t.i32(), y: t.i32() });
const entity = table({ name: 'entity' }, {
id: t.u64().primaryKey().autoInc(),
pos: Position,
});
// Sum type (tagged union):
const Shape = t.enum('Shape', {
circle: t.i32(),
rectangle: t.object('Rect', { w: t.i32(), h: t.i32() }),
});
// Values: { tag: 'circle', value: 10 }Views
View
A client subscribing to a view receives only the rows it returns. Use a per-user view
(keyed on ) for per-viewer access control: deleting a row it depends on
(e.g. a membership row) automatically drops the rows it was exposing from that client.
ctx.sendert.row(...)t.object(...)Array<{ sku: bigint; label: string }>DiscountedProductdiscounted_productWithout parameters, and take view options, a return schema, and a callback. Parameterized views add an argument-schema object before the return schema.
spacetimedb.view(...)spacetimedb.anonymousView(...)typescript
// Anonymous view (same for all clients):
export const activeUsers = spacetimedb.anonymousView(
{ name: 'active_users', public: true },
t.array(entity.rowType),
(ctx) => [...ctx.db.entity.iter()].filter(e => e.active)
);
// Per-user view (varies by ctx.sender):
export const myProfile = spacetimedb.view(
{ name: 'my_profile', public: true },
t.option(entity.rowType),
(ctx) => ctx.db.entity.identity.find(ctx.sender) ?? undefined
);For a procedural view primary key, define the output with and mark its field :
t.row.primaryKey()typescript
const CatalogKey = t.row('CatalogKey', {
sku: t.u64().primaryKey(),
label: t.string(),
});Query-builder views use and return the query directly. Because a query returns a row set, declare its return schema as . Use for predicates and when the result should contain right-side rows that have a matching left-side row:
ctx.fromt.array(tableName.rowType)whererightSemijointypescript
ctx => ctx.from.article.where(article => article.published.eq(true))
ctx => ctx.from.subscription.rightSemijoin(
ctx.from.account,
(subscription, account) => subscription.accountId.eq(account.id)
)The method name identifies which side is returned: returns rows from , while returns rows from . To return one table's rows when another table has a match, put the returned table on the corresponding side. Semijoins do not project combined columns from both tables.
A.leftSemijoin(B, ...)AA.rightSemijoin(B, ...)BProcedural views read through and return materialized values such as arrays. Query-builder values from are returned directly; they are not iterators and cannot be spread, looped over, or mixed with array methods. Use a procedural view when the result is a custom row assembled from multiple tables.
ctx.dbctx.fromParameterized views (2.13+) pass arguments to the callback as an object:
typescript
export const entityById = spacetimedb.anonymousView(
{ public: true },
{ identity: t.identity() },
t.option(entity.rowType),
(ctx, { identity }) => ctx.db.entity.identity.find(identity) ?? undefined
);Floating-point arguments are rejected. Views with arguments have no primary key; TypeScript omits primary-key metadata from the returned row type automatically. See client skills for SQL subscription syntax and restrictions.
客户端订阅 View 时,只会收到它返回的行。使用以 为键的按用户 View 可以实现按查看者进行访问控制:删除 View 所依赖的行(例如成员关系行)时,会自动从该客户端移除此前公开的相关行。
ctx.sendert.row(...)t.object(...)Array<{ sku: bigint; label: string }>discounted_productDiscountedProduct没有参数时, 和 依次接收 View 选项、返回 schema 和回调。带参数 View 会在返回 schema 之前增加一个参数 schema 对象。
spacetimedb.view(...)spacetimedb.anonymousView(...)typescript
// Anonymous view (same for all clients):
export const activeUsers = spacetimedb.anonymousView(
{ name: 'active_users', public: true },
t.array(entity.rowType),
(ctx) => [...ctx.db.entity.iter()].filter(e => e.active)
);
// Per-user view (varies by ctx.sender):
export const myProfile = spacetimedb.view(
{ name: 'my_profile', public: true },
t.option(entity.rowType),
(ctx) => ctx.db.entity.identity.find(ctx.sender) ?? undefined
);对于过程式 View 的主键,请使用 定义输出,并将其字段标记为 :
t.row.primaryKey()typescript
const CatalogKey = t.row('CatalogKey', {
sku: t.u64().primaryKey(),
label: t.string(),
});查询构建器 View 使用 并直接返回查询。由于查询返回行集,请将返回 schema 声明为 。使用 指定条件;当结果应包含存在匹配左侧行的右侧行时,使用 :
ctx.fromt.array(tableName.rowType)whererightSemijointypescript
ctx => ctx.from.article.where(article => article.published.eq(true))
ctx => ctx.from.subscription.rightSemijoin(
ctx.from.account,
(subscription, account) => subscription.accountId.eq(account.id)
)方法名称决定返回哪一侧: 返回 的行,而 返回 的行。要在另一张表存在匹配项时返回某张表的行,请将返回表放在对应一侧。半连接不会投影两张表的组合列。
A.leftSemijoin(B, ...)AA.rightSemijoin(B, ...)B过程式 View 通过 读取数据并返回已物化的值,例如数组。 返回的查询构建器值应直接返回;它们不是迭代器,不能展开、循环遍历或与数组方法混合使用。当结果是由多张表组装的自定义行时,请使用过程式 View。
ctx.dbctx.from带参数 View(2.13+)会以对象形式将参数传给回调:
typescript
export const entityById = spacetimedb.anonymousView(
{ public: true },
{ identity: t.identity() },
t.option(entity.rowType),
(ctx, { identity }) => ctx.db.entity.identity.find(identity) ?? undefined
);不允许使用浮点数参数。带参数的 View 没有主键;TypeScript 会自动从返回的行类型中省略主键元数据。有关 SQL 订阅语法和限制,请参阅客户端 skills。
Client Visibility Filters
客户端可见性过滤器
typescript
export const privateNoteFilter = spacetimedb.clientVisibilityFilter.sql(
'SELECT * FROM owned_row WHERE owner = :sender'
);typescript
export const privateNoteFilter = spacetimedb.clientVisibilityFilter.sql(
'SELECT * FROM owned_row WHERE owner = :sender'
);Procedures and HTTP
Procedure 与 HTTP
spacetimedbschema({...})spacetimedb/serverProcedures declare argument and return types:
typescript
const Result = t.object('Result', { value: t.string() });
export const inspect = spacetimedb.procedure(
{ input: t.string() },
Result,
(_ctx, { input }) => ({ value: input })
);Procedure callbacks are synchronous. Do not mark them or use ; return the declared value directly. A procedure with return type returns .
asyncawaitt.unit(){}TypeScript outbound HTTP uses , including for non-GET requests; it does not provide convenience methods such as or . Responses expose the numeric , , and APIs.
ctx.http.fetch(url, options)get()post()statusheaders.get(name)text()t.array(t.u8())number[]new Uint8Array(value)Procedures and handlers open short database transactions with . Perform network I/O before opening the transaction; only database work belongs inside its callback.
ctx.withTx(tx => ...)Scheduled procedures use the same binding as scheduled reducers. The procedure must be exported, return , and accept the scheduled row as its argument.
onSchedulet.unit()Inbound HTTP uses , , , and :
httpHandlerhttpRouterRouterSyncResponsehttpHandlerhttpRouterspacetimedbtypescript
import { Router, SyncResponse } from 'spacetimedb/server';
export const health = spacetimedb.httpHandler((_ctx, _request) =>
new SyncResponse('ok', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
);
export const routes = spacetimedb.httpRouter(new Router().get('/health', health));Handlers are synchronous: return directly rather than marking the callback . Pass the exported value to the router, not its raw callback. The router selects the path, while a handler reads request data with APIs such as ; has no property. A handler context does not expose ; use when a handler needs transactional database access.
SyncResponseasynchttpHandler(...)request.text()Requestpathctx.dbctx.withTx(tx => ...)spacetimeschema({...})spacetime/serverProcedure 需要声明参数类型和返回类型:
typescript
const Result = t.object('Result', { value: t.string() });
export const inspect = spacetimedb.procedure(
{ input: t.string() },
Result,
(_ctx, { input }) => ({ value: input })
);Procedure 回调是同步的。不要将其标记为 ,也不要使用 ;请直接返回声明的值。返回类型为 的 Procedure 返回 。
asyncawaitt.unit(){}TypeScript 出站 HTTP 使用 ,非 GET 请求也使用此方法;它不提供 或 等便捷方法。响应提供数值型 、 和 API。
ctx.http.fetch(url, options)get()post()statusheaders.get(name)text()t.array(t.u8())number[]new Uint8Array(value)Procedure 和 Handler 使用 开启短数据库事务。请在开启事务前完成网络 I/O;只有数据库操作应放在事务回调内。
ctx.withTx(tx => ...)计划任务 Procedure 与计划任务 Reducer 使用相同的 绑定。Procedure 必须导出、返回 ,并接受计划任务行作为参数。
onSchedulet.unit()入站 HTTP 使用 、、 和 :
httpHandlerhttpRouterRouterSyncResponsehttpHandlerhttpRouterspacetimedbtypescript
import { Router, SyncResponse } from 'spacetimedb/server';
export const health = spacetimedb.httpHandler((_ctx, _request) =>
new SyncResponse('ok', {
status: 200,
headers: { 'content-type': 'text/plain' },
})
);
export const routes = spacetimedb.httpRouter(new Router().get('/health', health));Handler 是同步的:请直接返回 ,不要将回调标记为 。请将导出的 值传给路由器,而不是传入其原始回调。路由器负责选择路径,Handler 则通过 等 API 读取请求数据; 没有 属性。Handler 上下文不提供 ;当 Handler 需要事务性数据库访问时,请使用 。
SyncResponseasynchttpHandler(...)request.text()Requestpathctx.dbctx.withTx(tx => ...)Module Unit Tests
模块单元测试
Use 2.12+ and Node.js 22+ in the module project, not the client. Set in and install the test runner with . Configure the host-import replacement:
spacetimedb"type": "module"package.jsonnpm install --save-dev vitest@^3.2.4typescript
// vitest.config.ts
import { defineConfig, type Plugin } from 'vitest/config';
import { spacetimedbModuleTestPlugin } from 'spacetimedb/server/test-utils/vitest';
export default defineConfig({
plugins: [spacetimedbModuleTestPlugin() as unknown as Plugin],
test: { environment: 'node' },
});typescript
// src/index.test.ts, for the addRecord reducer above
import { expect, it } from 'vitest';
import { createModuleTestHarness, TestAuth } from 'spacetimedb/server/test-utils';
import spacetimedb, * as moduleExports from './index';
it('inserts a record', () => {
const test = createModuleTestHarness(spacetimedb, moduleExports);
test.withReducerTx(TestAuth.internal(), ctx => {
moduleExports.addRecord(ctx, { value: 1 });
});
expect(test.db.scoreRecord.count()).toBe(1n);
});Run . Create a fresh harness per test. commits on normal return and rolls back/rethrows on exceptions; do not catch a failure inside the transaction if rollback is expected. Callbacks must be synchronous. Use for client authorization tests; internal auth represents the database, not a client or the publisher. The harness exposes , , procedure/view contexts, and query execution. It does not simulate subscriptions, RLS, lifecycle calls, or scheduling; invoke lifecycle/scheduled reducers explicitly and retain live integration tests for those host behaviors.
npx vitest runwithReducerTxTestAuth.fromJwtPayload(JSON.stringify({ iss: 'test', sub: 'alice' }), connectionId)clockrng请在模块项目中使用 2.12+ 和 Node.js 22+,而不是在客户端中使用。在 中设置 ,并使用 安装测试运行器。配置 host-import 替换:
spacetimepackage.json"type": "module"npm install --save-dev vitest@^3.2.4typescript
// vitest.config.ts
import { defineConfig, type Plugin } from 'vitest/config';
import { spacetimedbModuleTestPlugin } from 'spacetimedb/server/test-utils/vitest';
export default defineConfig({
plugins: [spacetimedbModuleTestPlugin() as unknown as Plugin],
test: { environment: 'node' },
});typescript
// src/index.test.ts, for the addRecord reducer above
import { expect, it } from 'vitest';
import { createModuleTestHarness, TestAuth } from 'spacetimedb/server/test-utils';
import spacetimedb, * as moduleExports from './index';
it('inserts a record', () => {
const test = createModuleTestHarness(spacetimedb, moduleExports);
test.withReducerTx(TestAuth.internal(), ctx => {
moduleExports.addRecord(ctx, { value: 1 });
});
expect(test.db.scoreRecord.count()).toBe(1n);
});运行 。每个测试都应创建新的 Harness。 在正常返回时提交,在发生异常时回滚并重新抛出;如果预期需要回滚,不要在事务内部捕获失败。回调必须是同步的。对于客户端授权测试,请使用 ;internal auth 表示数据库,而不是客户端或发布者。Harness 提供 、、Procedure/View 上下文和查询执行能力。它不会模拟订阅、RLS、生命周期调用或计划任务;请显式调用生命周期/计划任务 Reducer,并保留实时集成测试来验证这些宿主行为。
npx vitest runwithReducerTxTestAuth.fromJwtPayload(JSON.stringify({ iss: 'test', sub: 'alice' }), connectionId)clockrng