Loading...
Loading...
SpacetimeDB TypeScript server module SDK reference. Use when writing tables, reducers, or module logic in TypeScript.
npx skill4agent add clockworklabs/spacetimedb typescript-servertable()schema()export constimport { 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: { ... } })spacetimedb/serverScheduleAtTimestampConnectionIdspacetimedbRangespacetimedb/serverimport {
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)nameconst entity = table(
{ name: 'entity', public: true },
{
identity: t.identity().primaryKey(),
name: t.string(),
active: t.bool(),
}
);namepublic: trueevent: trueindexes: [...]ctx.dbschema({...})schema({ scoreRecord })ctx.db.scoreRecordname: 'snake_case't| Builder | JS type | Notes |
|---|---|---|
| number | |
| number | |
| bigint | Use |
| bigint | Use |
| bigint | |
| number | |
| boolean | |
| string | |
| Identity | |
| ConnectionId | |
| Timestamp | |
| TimeDuration | |
| ScheduleAt |
.primaryKey().autoInc().unique().index('btree').default(value).default(value)nickname: t.option(t.string())t.u16()numberu16.index('btree')indexesindexescolumns.index('btree')// 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(groupId)schema.tsindex.ts// index.ts
export { default } from './schema'; // re-export the schema for the module entryspacetimedb.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) }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 PKctx.db.scoreRecord.insert(...)insert(...)insert(...)eventIdctx.db.event.eventIdctx.db.event.idctx.dbiter()filter().sort().filter().map()export constexport const init = spacetimedb.init((ctx) => { ... });
export const onConnect = spacetimedb.clientConnected((ctx) => { ... });
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { ... });ctx.connectionIdConnectionId | nullctxReducerCtx<InferSchema<typeof spacetimedb>>anybigintnumbertype 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));
// 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 Identityctx.sendert.identity()ConnectionIdnew ConnectionId(value)ConnectionIdspacetimedbnew Timestamp(micros)TimestampspacetimedbRangespacetimedb/serverimport { 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) },
));scheduledimport { 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; // bigintimport * 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@spacetimedb/cron@spacetimedb/retry@spacetimedb/rate-limit@spacetimedb/crypto@spacetimedb/rate-limit/submoduleconst 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 | nulldependenciesspacetime.jsonspacetime generate <sending-database>src/remote_bindings/{
"database": "lobby",
"module-path": "./lobby",
"dependencies": [{ "name": "game_world", "database": "game-world" }],
"children": [{ "database": "game-world", "module-path": "./game_world" }]
}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}`);
}
);ctx.senderclientConnectedclientDisconnectedclientConnectedonResultIdentitygw.identity()Identity// 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.sendert.row(...)t.object(...)Array<{ sku: bigint; label: string }>DiscountedProductdiscounted_productspacetimedb.view(...)spacetimedb.anonymousView(...)// 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.fromt.array(tableName.rowType)whererightSemijoinctx => 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, ...)Bctx.dbctx.fromexport const entityById = spacetimedb.anonymousView(
{ public: true },
{ identity: t.identity() },
t.option(entity.rowType),
(ctx, { identity }) => ctx.db.entity.identity.find(identity) ?? undefined
);export const privateNoteFilter = spacetimedb.clientVisibilityFilter.sql(
'SELECT * FROM owned_row WHERE owner = :sender'
);spacetimedbschema({...})spacetimedb/serverconst Result = t.object('Result', { value: t.string() });
export const inspect = spacetimedb.procedure(
{ input: t.string() },
Result,
(_ctx, { input }) => ({ value: input })
);asyncawaitt.unit(){}ctx.http.fetch(url, options)get()post()statusheaders.get(name)text()t.array(t.u8())number[]new Uint8Array(value)ctx.withTx(tx => ...)onSchedulet.unit()httpHandlerhttpRouterRouterSyncResponsehttpHandlerhttpRouterspacetimedbimport { 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));SyncResponseasynchttpHandler(...)request.text()Requestpathctx.dbctx.withTx(tx => ...)spacetimedb"type": "module"package.jsonnpm install --save-dev vitest@^3.2.4// 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' },
});// 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 runwithReducerTxTestAuth.fromJwtPayload(JSON.stringify({ iss: 'test', sub: 'alice' }), connectionId)clockrng