typescript-server

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SpacetimeDB TypeScript SDK Reference

SpacetimeDB TypeScript SDK 参考

Module Structure

模块结构

Tables are built with
table()
, bound with
schema()
, and exported as default. Reducers and lifecycle hooks are
export const
:
typescript
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({...})
accepts table definitions and submodule namespace registrations. Row and object builders used as reducer arguments or view return types are not schema entries. Environment declarations go in the second argument:
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.
表使用
table()
构建、通过
schema()
绑定,并作为默认导出。Reducer 和生命周期钩子使用
export const
:
typescript
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({...})
接受表定义和子模块命名空间注册。用作 Reducer 参数或 View 返回类型的行构建器和对象构建器不属于 schema 条目。环境变量声明放在第二个参数中:
schema(tables, { env: { ... } })
。
需要注册时,请导出默认 schema,以及每个 Reducer、生命周期钩子、View、Procedure、HTTP export 和可见性过滤器。根入口的命名导出必须是已注册的 SpacetimeDB 值:普通根级辅助函数和常量不要导出。子模块库可以导出普通辅助函数供使用方调用,因为子模块发现会忽略非模块导出;不要从根入口重新导出这些辅助函数。

Imports

导入

Schema builders and module exports come from
spacetimedb/server
. Runtime value classes such as
ScheduleAt
,
Timestamp
, and
ConnectionId
come from the root
spacetimedb
package;
Range
comes from
spacetimedb/server
:
typescript
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/server
。
ScheduleAt
、
Timestamp
和
ConnectionId
等运行时值类来自根包
spacetimedb
;
Range
来自
spacetimedb/server
:
typescript
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)
takes two arguments. The
name
field MUST be snake_case:
typescript
const entity = table(
  { name: 'entity', public: true },
  {
    identity: t.identity().primaryKey(),
    name: t.string(),
    active: t.bool(),
  }
);
Options:
name
(snake_case, recommended),
public: true
,
event: true
,
indexes: [...]
ctx.db
accessors are the keys passed to
schema({...})
, verbatim:
schema({ scoreRecord })
->
ctx.db.scoreRecord
. Keep TypeScript identifiers and accessors camelCase. Use explicit
name: 'snake_case'
strings when you need a canonical database name that differs from the TypeScript identifier.
table(OPTIONS, COLUMNS)
接收两个参数。
name
字段必须为 snake_case:
typescript
const entity = table(
  { name: 'entity', public: true },
  {
    identity: t.identity().primaryKey(),
    name: t.string(),
    active: t.bool(),
  }
);
选项:
name
(snake_case,推荐)、
public: true
、
event: true
、
indexes: [...]
ctx.db
访问器就是传给
schema({...})
的键,并且会原样使用:
schema({ scoreRecord })
->
ctx.db.scoreRecord
。请让 TypeScript 标识符和访问器保持 camelCase。需要使用与 TypeScript 标识符不同的规范数据库名称时,请显式指定
name: 'snake_case'
字符串。

Column Types

列类型

Every column is a
t
builder value:
BuilderJS typeNotes
t.u8()
/
t.u16()
/
t.u32()
number
t.i8()
/
t.i16()
/
t.i32()
number
t.u64()
bigintUse
0n
literals
t.i64()
bigintUse
0n
literals
t.u128()
/
t.i128()
/
t.u256()
/
t.i256()
bigint
t.f64()
/
t.f32()
number
t.bool()
boolean
t.string()
string
t.identity()
Identity
t.connectionId()
ConnectionId
t.timestamp()
Timestamp
t.timeDuration()
TimeDuration
t.scheduleAt()
ScheduleAt
Modifiers:
.primaryKey()
,
.autoInc()
,
.unique()
,
.index('btree')
,
.default(value)
.
Use
.default(value)
only for a newly appended migration-safe field. Do not put defaults on primary-key, unique, or auto-increment columns.
Optional columns:
nickname: t.option(t.string())
Schema builders describe the database's wire types; they are not TypeScript type names. For example, a
t.u16()
value is a TypeScript
number
, not a value cast to a type named
u16
.
每一列都是一个
t
构建器值:
构建器JS 类型说明
t.u8()
/
t.u16()
/
t.u32()
number
t.i8()
/
t.i16()
/
t.i32()
number
t.u64()
bigint使用
0n
字面量
t.i64()
bigint使用
0n
字面量
t.u128()
/
t.i128()
/
t.u256()
/
t.i256()
bigint
t.f64()
/
t.f32()
number
t.bool()
boolean
t.string()
string
t.identity()
Identity
t.connectionId()
ConnectionId
t.timestamp()
Timestamp
t.timeDuration()
TimeDuration
t.scheduleAt()
ScheduleAt
修饰符:
.primaryKey()
、
.autoInc()
、
.unique()
、
.index('btree')
、
.default(value)
。
仅可对 newly appended 且迁移安全的字段使用
.default(value)
。不要在主键、唯一列或自增列上设置默认值。
可选列:
nickname: t.option(t.string())
Schema 构建器描述的是数据库的传输类型,而不是 TypeScript 类型名。例如,
t.u16()
值是 TypeScript
number
,而不是转换为名为
u16
的类型。

Indexes

索引

Use inline
.index('btree')
when a single-column index does not need a named accessor. Use an
indexes
entry when the accessor is named explicitly or the index spans multiple columns. Every
indexes
entry requires
columns
; do not also add
.index('btree')
to the same column.
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 (
schema.ts
) from reducers/lifecycle (
index.ts
), re-export it from the entry:
typescript
// index.ts
export { default } from './schema';   // re-export the schema for the module entry
当单列索引不需要命名访问器时,使用内联
.index('btree')
。需要显式命名访问器或索引涵盖多个列时,使用
indexes
条目。每个
indexes
条目都必须指定
columns
;不要对同一列再添加
.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 作为默认导出。如果将表(
schema.ts
)与 Reducer/生命周期钩子(
index.ts
)拆分,请从入口文件重新导出 schema:
typescript
// index.ts
export { default } from './schema';   // re-export the schema for the module entry

Reducers

Reducer

Reducers are created with
spacetimedb.reducer(...)
; the export name becomes the reducer name:
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:
{ splits: t.array(Split) }
. Do not pass JSON strings for structured data.
Reducer 使用
spacetimedb.reducer(...)
创建;导出名称会成为 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 参数接受任意列类型,包括自定义类型的数组:
{ splits: t.array(Split) }
。不要为结构化数据传入 JSON 字符串。

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 PK
Insert through the table accessor (
ctx.db.scoreRecord.insert(...)
). Primary-key, unique, and index accessors support lookup or mutation of existing rows, but do not have
insert(...)
.
insert(...)
returns the inserted row, including database-assigned auto-increment fields.
The accessor for a primary key or index is the declared column name. For example, a primary key named
eventId
is accessed as
ctx.db.event.eventId
, not
ctx.db.event.id
.
The schema value registers module exports but does not expose database rows. Pass a context into any helper that needs
ctx.db
.
Note:
iter()
and
filter()
return iterators. Spread to Array for
.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(...)
返回已插入的行,其中包括数据库分配的自增字段。
主键或索引的访问器就是声明的列名。例如,名为
eventId
的主键应通过
ctx.db.event.eventId
访问,而不是
ctx.db.event.id
。
Schema 值用于注册模块导出,但不会公开数据库行。需要访问
ctx.db
的辅助函数应接收上下文参数。
请注意,
iter()
和
filter()
返回迭代器。要使用
.sort()
、
.filter()
、
.map()
,请先展开为 Array。

Lifecycle Hooks

生命周期钩子

MUST be
export const
. Bare calls are silently ignored:
typescript
export const init = spacetimedb.init((ctx) => { ... });
export const onConnect = spacetimedb.clientConnected((ctx) => { ... });
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { ... });
ctx.connectionId
is
ConnectionId | null
, including in lifecycle contexts. Guard it before passing it to a helper or using it as a table key.
必须使用
export const
。裸调用会被静默忽略:
typescript
export const init = spacetimedb.init((ctx) => { ... });
export const onConnect = spacetimedb.clientConnected((ctx) => { ... });
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { ... });
即使在生命周期上下文中,
ctx.connectionId
也是
ConnectionId | null
。将其传给辅助函数或用作表键之前,请先进行空值检查。

Reducer Context API

Reducer 上下文 API

ctx
is the only source of sender identity, time, and randomness; stdlib clocks and RNG are unavailable in modules. Let exported callbacks infer their context type. In helpers, use
ReducerCtx<InferSchema<typeof spacetimedb>>
; do not annotate a context as
any
, because that erases table row types and can make
bigint
expressions infer as
number
.
typescript
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
Identity
values from strings (e.g.
'hex' as Identity
): serialization fails and kills the module. Identities come from
ctx.sender
or
t.identity()
columns.
Construct a
ConnectionId
from its numeric representation with
new ConnectionId(value)
after importing
ConnectionId
from
spacetimedb
.
Construct exact timestamps with
new Timestamp(micros)
after importing
Timestamp
from
spacetimedb
. Inclusive index ranges use
Range
from
spacetimedb/server
:
typescript
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) },
));
ctx
是发送者身份、时间和随机数的唯一来源;模块中无法使用标准库时钟和 RNG。让导出的回调自动推断其上下文类型。在辅助函数中使用
ReducerCtx<InferSchema<typeof spacetimedb>>
;不要将上下文标注为
any
,因为这会擦除表行类型,并可能使
bigint
表达式被推断为
number
。
typescript
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
值(例如
'hex' as Identity
):序列化会失败并导致模块终止。Identity 来自
ctx.sender
或
t.identity()
列。
从
spacetimedb
导入
ConnectionId
后,可通过其数值表示构造连接 ID:
new ConnectionId(value)
。
从
spacetimedb
导入
Timestamp
后,可通过微秒值构造精确时间戳:
new Timestamp(micros)
。包含边界值的索引范围使用来自
spacetimedb/server
的
Range
:
typescript
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
scheduled
option must be exported.
typescript
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
表的
scheduled
选项所引用的 Reducer 或 Procedure 必须导出。
typescript
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

Submodules

子模块

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;
myAuth
is the accessor (
ctx.db.myAuth.<table>
);
auth_data
is the canonical namespace used in SQL and wire names. Without an explicit
name
, the root's case policy derives it (
myAuth
→
my_auth
by default). Pin
name
when renaming an accessor: changing the canonical namespace drops its tables and their data. The library keeps its own flat
ctx.db
accessors. To call a library function directly, pass
ctx.as.myAuth
; inside
withTx
, use
tx.as.myAuth
. Nested TypeScript submodules are supported. Lifecycle reducers and nonempty environment declarations belong in the root. Submodule routers are ignored; register delegated handlers on the root router. Composition is not cross-language.
Official reusable libraries include
@spacetimedb/cron
,
@spacetimedb/retry
,
@spacetimedb/rate-limit
, and
@spacetimedb/crypto
. Their registration shapes differ:
@spacetimedb/rate-limit/submodule
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.
请导入库的完整模块命名空间对象,而不只是其默认 schema:
typescript
import * as authLib from 'auth_lib';

const spacetimedb = schema({
  scoreRecord,
  myAuth: { name: 'auth_data', module: authLib },
});
export default spacetimedb;
myAuth
是访问器(
ctx.db.myAuth.<table>
);
auth_data
是 SQL 和线上名称中使用的规范命名空间。未显式指定
name
时,根模块的命名策略会推导该名称(默认情况下
myAuth
->
my_auth
)。重命名访问器时应固定
name
:更改规范命名空间会删除其表及表中的数据。该库会保留自己的扁平
ctx.db
访问器。要直接调用库函数,请传入
ctx.as.myAuth
;在
withTx
内部,使用
tx.as.myAuth
。支持嵌套 TypeScript 子模块。生命周期 Reducer 和非空环境变量声明应放在根模块中。子模块路由器会被忽略;请在根路由器上注册委托处理器。不支持跨语言组合。
官方可复用库包括
@spacetimedb/cron
、
@spacetimedb/retry
、
@spacetimedb/rate-limit
和
@spacetimedb/crypto
。它们的注册形式各不相同:
@spacetimedb/rate-limit/submodule
可以挂载,cron/retry 提供工厂,crypto 导出辅助函数。请查阅所选包的 README/exports,不要假定每个包根模块都可挂载。

Environment 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 | null
See 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
dependencies
in the sending database's
spacetime.json
, then running
spacetime generate <sending-database>
. TypeScript bindings are written to
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,
    ctx.sender
    is the sending database's identity, so authorize calls by checking it against an allow-list table.
  • The receiving database runs
    clientConnected
    and
    clientDisconnected
    around every delivered call, with no JWT. If
    clientConnected
    rejects the sending database, the call is retried forever.
  • 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
    Identity
    can't be a target. Use values typed
    gw.identity()
    , such as reducer arguments or columns of that type. Index lookups on those columns accept a plain
    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.
Reducer 可以通过向 outbox 表插入一行来调用另一个数据库中的 Reducer。仅当事务提交时调用才会发送;调用恰好执行一次,并与通过同一 outbox 发送到同一数据库的其他调用按顺序执行。发送方 Reducer 不会等待它完成。两个数据库必须位于同一服务器上。
要生成接收方绑定,请先在发送方数据库的
spacetime.json
中通过
dependencies
列出接收模块,然后运行
spacetime generate <sending-database>
。TypeScript 绑定将写入
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,无需修改。在其中,
    ctx.sender
    是发送方数据库的 Identity,因此应通过白名单表检查调用方权限。
  • 每次传递调用时,接收方数据库都会在调用前后运行
    clientConnected
    和
    clientDisconnected
    ,且不使用 JWT。如果
    clientConnected
    拒绝发送方数据库,该调用将被无限重试。
  • Outbox 行仅支持插入,因此无法取消待处理调用。调用一旦产生结果就不会重试。要再次尝试,请插入新行,例如在
    onResult
    中插入。
  • 普通
    Identity
    不能作为目标。请使用由
    gw.identity()
    类型化的值,例如 Reducer 参数或该类型的列。对这些列执行索引查找时,可以接受普通
    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
ctx.sender
) 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.
t.row(...)
and
t.object(...)
return schema builders, not TypeScript runtime row types. Let a view callback infer its result, or annotate a separately declared structural type such as
Array<{ sku: bigint; label: string }>
. A named output type must not reuse the generated PascalCase name of its view accessor (for example, reserve
DiscountedProduct
for a
discounted_product
view).
Without parameters,
spacetimedb.view(...)
and
spacetimedb.anonymousView(...)
take view options, a return schema, and a callback. Parameterized views add an argument-schema object before the return schema.
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
t.row
and mark its field
.primaryKey()
:
typescript
const CatalogKey = t.row('CatalogKey', {
  sku: t.u64().primaryKey(),
  label: t.string(),
});
Query-builder views use
ctx.from
and return the query directly. Because a query returns a row set, declare its return schema as
t.array(tableName.rowType)
. Use
where
for predicates and
rightSemijoin
when the result should contain right-side rows that have a matching left-side row:
typescript
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:
A.leftSemijoin(B, ...)
returns rows from
A
, while
A.rightSemijoin(B, ...)
returns rows from
B
. 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.
Procedural views read through
ctx.db
and return materialized values such as arrays. Query-builder values from
ctx.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.
Parameterized 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 时,只会收到它返回的行。使用以
ctx.sender
为键的按用户 View 可以实现按查看者进行访问控制:删除 View 所依赖的行(例如成员关系行)时,会自动从该客户端移除此前公开的相关行。
t.row(...)
和
t.object(...)
返回 schema 构建器,而不是 TypeScript 运行时行类型。让 View 回调自动推断结果类型,或标注单独声明的结构类型,例如
Array<{ sku: bigint; label: string }>
。命名输出类型不得复用 View 访问器生成的 PascalCase 名称(例如,应为
discounted_product
View 保留
DiscountedProduct
)。
没有参数时,
spacetimedb.view(...)
和
spacetimedb.anonymousView(...)
依次接收 View 选项、返回 schema 和回调。带参数 View 会在返回 schema 之前增加一个参数 schema 对象。
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 使用
ctx.from
并直接返回查询。由于查询返回行集,请将返回 schema 声明为
t.array(tableName.rowType)
。使用
where
指定条件;当结果应包含存在匹配左侧行的右侧行时,使用
rightSemijoin
:
typescript
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, ...)
返回
A
的行,而
A.rightSemijoin(B, ...)
返回
B
的行。要在另一张表存在匹配项时返回某张表的行,请将返回表放在对应一侧。半连接不会投影两张表的组合列。
过程式 View 通过
ctx.db
读取数据并返回已物化的值,例如数组。
ctx.from
返回的查询构建器值应直接返回;它们不是迭代器,不能展开、循环遍历或与数组方法混合使用。当结果是由多张表组装的自定义行时,请使用过程式 View。
带参数 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

spacetimedb
is the local schema value returned by
schema({...})
; it is not a named export to import from
spacetimedb/server
.
Procedures 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
async
or use
await
; return the declared value directly. A procedure with return type
t.unit()
returns
{}
.
TypeScript outbound HTTP uses
ctx.http.fetch(url, options)
, including for non-GET requests; it does not provide convenience methods such as
get()
or
post()
. Responses expose the numeric
status
,
headers.get(name)
, and
text()
APIs.
t.array(t.u8())
values are
number[]
. Convert one to
new Uint8Array(value)
before using it as a binary request body.
Procedures and handlers open short database transactions with
ctx.withTx(tx => ...)
. Perform network I/O before opening the transaction; only database work belongs inside its callback.
Scheduled procedures use the same
onSchedule
binding as scheduled reducers. The procedure must be exported, return
t.unit()
, and accept the scheduled row as its argument.
Inbound HTTP uses
httpHandler
,
httpRouter
,
Router
, and
SyncResponse
:
httpHandler
and
httpRouter
are methods on the local
spacetimedb
schema value, not named imports.
typescript
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
SyncResponse
directly rather than marking the callback
async
. Pass the exported
httpHandler(...)
value to the router, not its raw callback. The router selects the path, while a handler reads request data with APIs such as
request.text()
;
Request
has no
path
property. A handler context does not expose
ctx.db
; use
ctx.withTx(tx => ...)
when a handler needs transactional database access.
spacetime
是
schema({...})
返回的本地 schema 值;它不是从
spacetime/server
导入的命名导出。
Procedure 需要声明参数类型和返回类型:
typescript
const Result = t.object('Result', { value: t.string() });

export const inspect = spacetimedb.procedure(
  { input: t.string() },
  Result,
  (_ctx, { input }) => ({ value: input })
);
Procedure 回调是同步的。不要将其标记为
async
,也不要使用
await
;请直接返回声明的值。返回类型为
t.unit()
的 Procedure 返回
{}
。
TypeScript 出站 HTTP 使用
ctx.http.fetch(url, options)
,非 GET 请求也使用此方法;它不提供
get()
或
post()
等便捷方法。响应提供数值型
status
、
headers.get(name)
和
text()
API。
t.array(t.u8())
的值是
number[]
。将其用作二进制请求体之前,请先通过
new Uint8Array(value)
转换。
Procedure 和 Handler 使用
ctx.withTx(tx => ...)
开启短数据库事务。请在开启事务前完成网络 I/O;只有数据库操作应放在事务回调内。
计划任务 Procedure 与计划任务 Reducer 使用相同的
onSchedule
绑定。Procedure 必须导出、返回
t.unit()
,并接受计划任务行作为参数。
入站 HTTP 使用
httpHandler
、
httpRouter
、
Router
和
SyncResponse
:
httpHandler
和
httpRouter
是本地
spacetimedb
schema 值上的方法,不是命名导入。
typescript
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 是同步的:请直接返回
SyncResponse
,不要将回调标记为
async
。请将导出的
httpHandler(...)
值传给路由器,而不是传入其原始回调。路由器负责选择路径,Handler 则通过
request.text()
等 API 读取请求数据;
Request
没有
path
属性。Handler 上下文不提供
ctx.db
;当 Handler 需要事务性数据库访问时,请使用
ctx.withTx(tx => ...)
。

Module Unit Tests

模块单元测试

Use
spacetimedb
2.12+ and Node.js 22+ in the module project, not the client. Set
"type": "module"
in
package.json
and install the test runner with
npm install --save-dev vitest@^3.2.4
. Configure the host-import replacement:
typescript
// 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
npx vitest run
. Create a fresh harness per test.
withReducerTx
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
TestAuth.fromJwtPayload(JSON.stringify({ iss: 'test', sub: 'alice' }), connectionId)
for client authorization tests; internal auth represents the database, not a client or the publisher. The harness exposes
clock
,
rng
, 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.
请在模块项目中使用
spacetime
2.12+ 和 Node.js 22+,而不是在客户端中使用。在
package.json
中设置
"type": "module"
,并使用
npm install --save-dev vitest@^3.2.4
安装测试运行器。配置 host-import 替换:
typescript
// 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);
});
运行
npx vitest run
。每个测试都应创建新的 Harness。
withReducerTx
在正常返回时提交,在发生异常时回滚并重新抛出;如果预期需要回滚,不要在事务内部捕获失败。回调必须是同步的。对于客户端授权测试,请使用
TestAuth.fromJwtPayload(JSON.stringify({ iss: 'test', sub: 'alice' }), connectionId)
;internal auth 表示数据库,而不是客户端或发布者。Harness 提供
clock
、
rng
、Procedure/View 上下文和查询执行能力。它不会模拟订阅、RLS、生命周期调用或计划任务;请显式调用生命周期/计划任务 Reducer,并保留实时集成测试来验证这些宿主行为。