typescript-server について
SpacetimeDB のサーバーモジュールを構築するための TypeScript SDK リファレンスです。スキーマ、テーブル、Reducer、ビュー、Procedure、HTTP 処理、データベース操作を解説します。
主な機能
- 01スキーマとテーブルの定義
- 02Reducer とライフサイクルフック
- 03インデックス付きデータベース操作
- 04手続き型ビューとクエリビルダービュー
- 05Procedure と HTTP 処理
- 06カスタム型とスケジュールテーブル
活用シーン
- 01モジュールのデータベーススキーマ定義
- 02Reducer とライフサイクル処理の実装
- 03ビューによるデータの絞り込み公開
- 04Procedure または HTTP エンドポイントの追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ clockworklabs/ spacetimedb --skill typescript-serverインストール後、Claude Code で/typescript-serverを入力して読み込まれたか確認します。
詳細
SpacetimeDB TypeScript SDK リファレンス
モジュール構造
テーブルは table() で構築し、schema() にバインドして、default としてエクスポートします。リデューサーとライフサイクルフックは export const でエクスポートします。
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({...}) に含めるのはテーブル定義だけです。リデューサーの引数やビューの戻り値の型として使用する行ビルダーやオブジェクトビルダーは、スキーマのエントリーではありません。
名前付きのランタイムエクスポートは、リデューサー、ライフサイクルフック、ビュー、プロシージャ、HTTP エクスポート、可視性フィルターなど、SpacetimeDB に登録される値に限定します。通常のヘルパー関数や定数はエクスポートしないでください。
インポート
スキーマビルダーとモジュールのエクスポートは spacetimedb/server からインポートします。ScheduleAt、Timestamp、ConnectionId などのランタイム値クラスはルートの spacetimedb パッケージから、Range は spacetimedb/server からインポートします。
import {
schema, table, t, SenderError,
type InferSchema, type ReducerCtx,
} from 'spacetimedb/server';
import { ConnectionId, ScheduleAt, Timestamp } from 'spacetimedb';
import { Range } from 'spacetimedb/server';
テーブル
table(OPTIONS, COLUMNS) は引数を2つ取ります。name フィールドは snake_case でなければなりません。
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' の文字列を指定します。
カラム型
すべてのカラムは 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)。
.default(value) は、新しく追加する、マイグレーションに対応したフィールドにのみ使用します。主キー、ユニーク、または自動インクリメントのカラムにはデフォルト値を設定しないでください。
オプションのカラム:nickname: t.option(t.string())
スキーマビルダーはデータベースのワイヤー型を記述するものであり、TypeScript の型名ではありません。たとえば、t.u16() の値は TypeScript の number であり、u16 という型名にキャストされた値ではありません。
インデックス
名前付きアクセサーが不要な単一カラムのインデックスには、インラインの .index('btree') を使用します。アクセサーを明示的に命名する場合や、インデックスが複数のカラムにまたがる場合は、indexes エントリーを使用します。すべての indexes エントリーには columns が必要です。同じカラムに .index('btree') も追加しないでください。
// Inline (preferred for single-column):
authorId: t.u64().index('btree'),
// Access: ctx.db.post.authorId.filter(authorId);
// Multi-column (named):
indexes: [{ accessor: 'by_group_user', algorithm: 'btree', columns: ['groupId', 'userId'] }]
// Access: ctx.db.membership.by_group_user.filter([groupId, userId]);
1つのカラムでフィルターしてループするよりも、複数カラムのインデックスを優先してください。filter には、インデックスのカラム順に配列を渡します。先頭の値を渡すと、プレフィックススキャンになります。例:filter(groupId)。
公開するモジュールのエントリーファイルは、スキーマを default としてエクスポートする必要があります。テーブル(schema.ts)とリデューサー/ライフサイクル(index.ts)を別ファイルに分ける場合は、エントリーから再エクスポートします。
// index.ts
export { default } from './schema'; // re-export the schema for the module entry
リデューサー
リデューサーは spacetimedb.reducer(...) で作成します。エクスポート名がリデューサー名になります。
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) => { ... });
リデューサーの引数には、カスタム型の配列を含む、任意のカラム型を指定できます。例:{ splits: t.array(Split) }。構造化データを JSON 文字列として渡さないでください。
DB 操作
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.id ではなく ctx.db.event.eventId でアクセスします。
スキーマ値はモジュールのエクスポートを登録しますが、データベースの行は公開しません。ctx.db を必要とするヘルパーには、コンテキストを渡してください。
注意:iter() と filter() はイテレーターを返します。.sort()、.filter()、.map() を使用するには、スプレッド構文で配列に変換してください。
ライフサイクルフック
必ず export const にしてください。単独の呼び出しは無視されます。
export const init = spacetimedb.init((ctx) => { ... });
export const onConnect = spacetimedb.clientConnected((ctx) => { ... });
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { ... });
ctx.connectionId は、ライフサイクルコンテキストでも ConnectionId | null です。ヘルパーに渡すときやテーブルキーとして使用するときは、null でないことを確認してください。
Reducer コンテキスト API
ctx は送信者の識別情報、時刻、乱数を取得する唯一の手段です。モジュール内では標準ライブラリの時計と乱数生成器を利用できません。エクスポートするコールバックのコンテキスト型は推論させてください。ヘルパーでは ReducerCtx<InferSchema<typeof spacetimedb>> を使用してください。コンテキストを any として注釈しないでください。テーブルの行の型情報が失われ、bigint の式が number として推論される場合があります。
type Ctx = ReducerCtx<InferSchema<typeof spacetimedb>>;
function findRecord(ctx: Ctx, id: bigint) {
return ctx.db.scoreRecord.id.find(id);
}
// 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));
// Client: Timestamp → Date
new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
文字列(例:'hex' as Identity)から Identity 値を構築しないでください。シリアル化に失敗し、モジュールが終了します。Identity は ctx.sender または t.identity() 列から取得してください。
数値表現から ConnectionId を構築するには、spacetimedb から ConnectionId をインポートしたうえで、new ConnectionId(value) を使用してください。
正確なタイムスタンプを構築するには、spacetimedb から Timestamp をインポートしたうえで、new Timestamp(micros) を使用してください。包含的なインデックス範囲には、spacetimedb/server の Range を使用します。
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 オプションが参照する Reducer またはプロシージャは、エクスポートする必要があります。
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
カスタム型
// 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 }
ビュー
ビューをサブスクライブしたクライアントに届くのは、ビューが返す行のみです。閲覧者ごとのアクセス制御には、ctx.sender をキーとするユーザーごとのビューを使用します。ビューが依存する行(例:メンバーシップ行)を削除すると、そのクライアントに公開されていた行も自動的に除外されます。
t.row(...) と t.object(...) が返すのはスキーマビルダーであり、TypeScript の実行時の行型ではありません。ビューコールバックの結果型は推論させるか、Array<{ sku: bigint; label: string }> のような、別途宣言した構造型で注釈してください。名前付き出力型には、ビューアクセサーの生成されたパスカルケース名を再利用しないでください(たとえば、discounted_product ビューには DiscountedProduct を予約しておきます)。
spacetimedb.view(...) と spacetimedb.anonymousView(...) は、どちらもビューオプション、宣言された戻り値のスキーマ、コールバックの3つの引数を取ります。
// 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
);
手続き型ビューの主キーを定義するには、出力を t.row で定義し、そのフィールドに .primaryKey() を指定します。
const CatalogKey = t.row('CatalogKey', {
sku: t.u64().primaryKey(),
label: t.string(),
});
クエリビルダービューでは ctx.from を使用し、クエリをそのまま返します。クエリは行の集合を返すため、戻り値のスキーマは t.array(tableName.rowType) として宣言してください。条件には where を使用し、結果に一致する左側の行を持つ右側の行を含める場合は rightSemijoin を使用します。
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 の行を返します。別のテーブルに一致する行があるときに一方のテーブルの行を返すには、返したいテーブルを対応する側に配置します。セミジョインは、両方のテーブルの列を結合して射影することはありません。
手続き型ビューは ctx.db を通じてデータを読み取り、配列などの実体化された値を返します。ctx.from のクエリビルダー値はそのまま返します。イテレーターではないため、スプレッド構文を使ったり、ループ処理したり、配列メソッドと組み合わせたりすることはできません。複数のテーブルから組み立てたカスタム行を結果として返す場合は、手続き型ビューを使用してください。
クライアント可視性フィルター
export const privateNoteFilter = spacetimedb.clientVisibilityFilter.sql(
'SELECT * FROM owned_row WHERE owner = :sender'
);
プロシージャと HTTP
spacetimedb は schema({...}) が返すローカルのスキーマ値であり、spacetimedb/server からインポートする名前付きエクスポートではありません。
プロシージャでは引数と戻り値の型を宣言します。
const Result = t.object('Result', { value: t.string() });
export const inspect = spacetimedb.procedure(
{ input: t.string() },
Result,
(_ctx, { input }) => ({ value: input })
);
プロシージャのコールバックは同期的です。async を付けたり await を使用したりせず、宣言した値を直接返してください。戻り値の型が t.unit() のプロシージャは {} を返します。
TypeScript の送信 HTTP では、GET 以外のリクエストも含め、ctx.http.fetch(url, options) を使用します。get() や post() などの便利なメソッドはありません。レスポンスでは、数値の status、headers.get(name)、text() の各 API が利用できます。
t.array(t.u8()) の値は number[] です。バイナリのリクエスト本文として使用する前に、new Uint8Array(value) に変換してください。
プロシージャとハンドラーは、ctx.withTx(tx => ...) で短時間のデータベーストランザクションを開始します。ネットワーク I/O はトランザクションを開始する前に行い、コールバック内ではデータベース処理のみを行ってください。
スケジュールされたプロシージャでは、スケジュールされたリデューサーと同じ onSchedule バインディングを使用します。プロシージャはエクスポートされ、t.unit() を返し、引数としてスケジュールされた行を受け取る必要があります。
受信 HTTP では、httpHandler、httpRouter、Router、SyncResponse を使用します。
httpHandler と httpRouter は、名前付きインポートではなく、ローカルの spacetimedb スキーマ値のメソッドです。
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));
ハンドラーは同期的です。コールバックに async を付けず、SyncResponse を直接返してください。ルーターには生のコールバックではなく、エクスポートされた httpHandler(...) の値を渡します。ルーターがパスを選択し、ハンドラーは request.text() などの API でリクエストデータを読み取ります。Request に path プロパティはありません。ハンドラーのコンテキストには ctx.db がありません。ハンドラーでトランザクションを使用したデータベースアクセスが必要な場合は、ctx.withTx(tx => ...) を使用してください。