Files
Jason Larabie 47a7794111 Add small module-test* as a compile check for http handlers (#5330)
# Description of Changes

HTTP handlers already have smoketest coverage, but in order to add to
`module-test`s all languages had to have parity as `module-test` has a
check to ensure schemas match.

The existing integration tests using `module-test` load SpacetimeDB in
memory, expanding these tests would require significant and potentially
ugly work to handle hosting for HTTP handlers. Instead, this PR adds
compile-only for each `module-test` with a matching handler + route.

# API and ABI breaking changes

N/A

# Expected complexity level and risk

1 - tiny addition to `module-test` for all languages

# Testing

- [x] `cargo test -p spacetimedb-schema module_test`
- [x] `cargo test -p spacetimedb-testing`
2026-06-19 17:40:34 +00:00

533 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─────────────────────────────────────────────────────────────────────────────
// IMPORTS
// ─────────────────────────────────────────────────────────────────────────────
import { ScheduleAt } from 'spacetimedb';
import {
Router,
schema,
SyncResponse,
table,
t,
type Infer,
type InferTypeOfRow,
errors,
} from 'spacetimedb/server';
// ─────────────────────────────────────────────────────────────────────────────
// TYPE ALIASES
// ─────────────────────────────────────────────────────────────────────────────
// Rust: pub type TestAlias = TestA
type TestAlias = TestA;
// ─────────────────────────────────────────────────────────────────────────────
// SUPPORT TYPES (SpacetimeType equivalents)
// ─────────────────────────────────────────────────────────────────────────────
// Rust: #[derive(SpacetimeType)] pub struct TestB { foo: String }
const testB = t.object('testB', {
foo: t.string(),
});
type TestB = Infer<typeof testB>;
// Rust: #[derive(SpacetimeType)] #[sats(name = "Namespace.TestC")] enum TestC { Foo, Bar }
const testC = t.enum('Namespace.TestC', {
Foo: t.unit(),
Bar: t.unit(),
});
type TestC = Infer<typeof testC>;
// Rust: const DEFAULT_TEST_C: TestC = TestC::Foo;
const DEFAULT_TEST_C: TestC = { tag: 'Foo' } as const;
// Rust: #[derive(SpacetimeType)] pub struct Baz { pub field: String }
const Baz = t.object('Baz', {
field: t.string(),
});
type Baz = Infer<typeof Baz>;
// Rust: #[derive(SpacetimeType)] pub enum Foobar { Baz(Baz), Bar, Har(u32) }
const foobar = t.enum('Foobar', {
Baz: Baz,
Bar: t.unit(),
Har: t.u32(),
});
type Foobar = Infer<typeof foobar>;
// Rust: #[derive(SpacetimeType)] #[sats(name = "Namespace.TestF")] enum TestF { Foo, Bar, Baz(String) }
const testF = t.enum('Namespace.TestF', {
Foo: t.unit(),
Bar: t.unit(),
Baz: t.string(),
});
type TestF = Infer<typeof testF>;
// Rust: #[derive(Deserialize)] pub struct Foo<'a> { pub field: &'a str }
// In TS we simply model as a struct and provide a BSATN deserializer placeholder.
const Foo = t.object('Foo', { field: t.string() });
type Foo = Infer<typeof Foo>;
function Foo_baz_bsatn(_bytes: Uint8Array): Foo {
// If your bindings expose a bsatn decode helper, use it here.
// return bsatn.fromSlice(bytes, Foo);
throw new Error('Implement BSATN decode for Foo if needed');
}
// ─────────────────────────────────────────────────────────────────────────────
// TABLE ROW DEFINITIONS (shape only)
// ─────────────────────────────────────────────────────────────────────────────
// Rust: #[spacetimedb::table(name = person, public, index(name = age, btree(columns = [age])))]
const personRow = {
id: t.u32().primaryKey().autoInc(),
name: t.string(),
age: t.u8(),
};
// Rust: #[spacetimedb::table(name = test_a, index(name = foo, btree(columns = [x])))]
const testA = t.row({
x: t.u32(),
y: t.u32(),
z: t.string(),
});
type TestA = Infer<typeof testA>;
// Rust: #[table(name = test_d, public)] struct TestD { #[default(Some(DEFAULT_TEST_C))] test_c: Option<TestC>, }
// NOTE: If your Option default requires wrapping, adjust to your bindings Option encoding.
const testDRow = {
test_c: t.option(testC).default(DEFAULT_TEST_C as unknown as any),
test_c_nested: t.option(t.array(testC)),
};
type TestD = InferTypeOfRow<typeof testDRow>;
// Rust: #[spacetimedb::table(name = test_e)] #[derive(Debug)]
const testERow = {
id: t.u64().primaryKey().autoInc(),
name: t.string(),
};
// Rust: #[table(name = test_f, public)] pub struct TestFoobar { pub field: Foobar }
const testFRow = {
field: foobar,
};
// Rust: #[spacetimedb::table(name = private_table, private)]
const privateTableRow = {
name: t.string(),
};
// Rust: #[spacetimedb::table(name = points, private, index(name = multi_column_index, btree(columns = [x, y])))]
const pointsRow = {
x: t.i64(),
y: t.i64(),
};
// Rust: #[spacetimedb::table(name = pk_multi_identity)]
const pkMultiIdentityRow = {
id: t.u32().primaryKey(),
other: t.u32().unique().autoInc(),
};
// Rust: #[spacetimedb::table(name = repeating_test_arg, scheduled(repeating_test))]
const repeatingTestArg = t.row({
scheduled_id: t.u64().primaryKey().autoInc(),
scheduled_at: t.scheduleAt(),
prev_time: t.timestamp(),
});
type RepeatingTestArg = Infer<typeof repeatingTestArg>;
// Rust: #[spacetimedb::table(name = nonrepeating_test_arg, scheduled(nonrepeating_test))]
const nonrepeatingTestArg = t.row({
scheduled_id: t.u64().primaryKey().autoInc(),
scheduled_at: t.scheduleAt(),
prev_time: t.timestamp(),
});
type NonrepeatingTestArg = Infer<typeof nonrepeatingTestArg>;
// Rust: #[spacetimedb::table(name = has_special_stuff)]
const hasSpecialStuffRow = {
identity: t.identity(),
connection_id: t.connectionId(),
};
// Rust: two tables with the same row type: player & logged_out_player
const playerLikeRow = t.row({
identity: t.identity().primaryKey(),
player_id: t.u64().autoInc().unique(),
name: t.string().unique(),
});
// ─────────────────────────────────────────────────────────────────────────────
// SCHEMA (tables + indexes + visibility)
// ─────────────────────────────────────────────────────────────────────────────
const spacetimedb = schema({
// person (public) with btree index on age
person: table(
{
public: true,
indexes: [{ accessor: 'age', algorithm: 'btree', columns: ['age'] }],
},
personRow
),
// test_a with index foo on x
testATable: table(
{
name: 'test_a',
indexes: [{ accessor: 'foo', algorithm: 'btree', columns: ['x'] }],
},
testA
),
// test_d (public) with default(Some(DEFAULT_TEST_C)) option field
testD: table({ public: true }, testDRow),
// test_e, default private, with primary key id auto_inc and btree index on name
testE: table(
{
name: 'test_e',
public: false,
indexes: [{ accessor: 'name', algorithm: 'btree', columns: ['name'] }],
},
testERow
),
// test_f (public) with Foobar field
testF: table({ public: true }, testFRow),
// private_table (explicit private)
privateTable: table(
{ name: 'private_table', public: false },
privateTableRow
),
// points (private) with multi-column btree index (x, y)
points: table(
{
name: 'points',
public: false,
indexes: [
{
accessor: 'multi_column_index',
algorithm: 'btree',
columns: ['x', 'y'],
},
],
},
pointsRow
),
// pk_multi_identity with multiple constraints
pkMultiIdentity: table({ name: 'pk_multi_identity' }, pkMultiIdentityRow),
// repeating_test_arg table with scheduled(repeating_test)
repeatingTestArg: table(
{
name: 'repeating_test_arg',
scheduled: (): any => repeatingTest,
},
repeatingTestArg
),
// nonrepeating_test_arg table with scheduled(nonrepeating_test)
nonrepeatingTestArg: table(
{
name: 'nonrepeating_test_arg',
scheduled: (): any => nonrepeatingTest,
},
nonrepeatingTestArg
),
// has_special_stuff with Identity and ConnectionId
hasSpecialStuff: table({ name: 'has_special_stuff' }, hasSpecialStuffRow),
// Two tables with the same row type: player and logged_out_player
player: table({ name: 'player', public: true }, playerLikeRow),
loggedOutPlayer: table(
{ name: 'logged_out_player', public: true },
playerLikeRow
),
tableToRemove: table({ name: 'table_to_remove' }, { id: t.u32() }),
});
export default spacetimedb;
// ─────────────────────────────────────────────────────────────────────────────
// VIEWS
// ─────────────────────────────────────────────────────────────────────────────
export const myPlayer = spacetimedb.view(
{ public: true },
playerLikeRow.optional(),
// FIXME: this should not be necessary; change `OptionBuilder` to accept `null|undefined` for `none`
ctx => ctx.db.player.identity.find(ctx.sender) ?? undefined
);
// ─────────────────────────────────────────────────────────────────────────────
// REDUCERS (mirroring Rust order & behavior)
// ─────────────────────────────────────────────────────────────────────────────
// init
export const init = spacetimedb.init(ctx => {
ctx.db.repeatingTestArg.insert({
prev_time: ctx.timestamp,
scheduled_id: 0n, // u64 autoInc placeholder (engine will assign)
scheduled_at: ScheduleAt.interval(1000000n), // 1000ms
});
const currentTimeMicros = ctx.timestamp.microsSinceUnixEpoch;
const oneSecond = 1_000_000n; // 1 second in microseconds
ctx.db.nonrepeatingTestArg.insert({
prev_time: ctx.timestamp,
scheduled_id: 1n,
scheduled_at: ScheduleAt.time(currentTimeMicros + oneSecond),
});
});
// repeating_test
export const repeatingTest = spacetimedb.reducer(
{ arg: repeatingTestArg },
(ctx, { arg }) => {
const delta = ctx.timestamp.since(arg.prev_time); // adjust if API differs
console.trace(`Timestamp: ${ctx.timestamp}, Delta time: ${delta}`);
}
);
// nonrepeating_test
export const nonrepeatingTest = spacetimedb.reducer(
{ arg: nonrepeatingTestArg },
(ctx, { arg }) => {
const delta = ctx.timestamp.since(arg.prev_time);
console.trace(
`This reducers runs only once, at Timestamp: ${ctx.timestamp}, Delta time: ${delta}`
);
}
);
// add(name, age)
export const add = spacetimedb.reducer(
{ name: t.string(), age: t.u8() },
(ctx, { name, age }) => {
ctx.db.person.insert({ id: 0, name, age });
}
);
// say_hello()
export const say_hello = spacetimedb.reducer(ctx => {
for (const person of ctx.db.person.iter()) {
console.info(`Hello, ${person.name}!`);
}
console.info('Hello, World!');
});
// list_over_age(age)
export const listOverAge = spacetimedb.reducer(
{ age: t.u8() },
(ctx, { age }) => {
// Prefer an index-based scan if exposed by bindings; otherwise iterate.
for (const person of ctx.db.person.iter()) {
if (person.age >= age) {
console.info(`${person.name} has age ${person.age} >= ${age}`);
}
}
}
);
// log_module_identity()
export const log_module_identity = spacetimedb.reducer(ctx => {
console.info(`Module identity: ${ctx.databaseIdentity}`);
});
// test(arg: TestAlias(TestA), arg2: TestB, arg3: TestC, arg4: TestF)
export const test = spacetimedb.reducer(
{ arg: testA, arg2: testB, arg3: testC, arg4: testF },
(ctx, { arg, arg2, arg3, arg4 }) => {
console.info('BEGIN');
console.info(`sender: ${ctx.sender}`);
console.info(`timestamp: ${ctx.timestamp}`);
console.info(`bar: ${arg2.foo}`);
// TestC
if (arg3.tag === 'Foo') console.info('Foo');
else if (arg3.tag === 'Bar') console.info('Bar');
// TestF
if (arg4.tag === 'Foo') console.info('Foo');
else if (arg4.tag === 'Bar') console.info('Bar');
else if (arg4.tag === 'Baz') console.info(arg4.value);
// Insert test_a rows
for (let i = 0; i < 1000; i++) {
ctx.db.testATable.insert({
x: (i >>> 0) + arg.x,
y: (i >>> 0) + arg.y,
z: 'Yo',
});
}
const rowCountBefore = ctx.db.testATable.count();
console.info(`Row count before delete: ${rowCountBefore}`);
// Delete rows by the indexed column `x` in [5,10)
let numDeleted = 0;
for (let x = 5; x < 10; x++) {
// Prefer index deletion if available; fallback to filter+delete
for (const row of ctx.db.testATable.iter()) {
if (row.x === x) {
if (ctx.db.testATable.delete(row)) numDeleted++;
}
}
}
const rowCountAfter = ctx.db.testATable.count();
if (Number(rowCountBefore) !== Number(rowCountAfter) + numDeleted) {
console.error(
`Started with ${rowCountBefore} rows, deleted ${numDeleted}, and wound up with ${rowCountAfter} rows... huh?`
);
}
// try_insert TestE { id: 0, name: "Tyler" }
try {
const inserted = ctx.db.testE.insert({ id: 0n, name: 'Tyler' });
console.info(`Inserted: ${JSON.stringify(inserted)}`);
} catch (err) {
console.info(`Error: ${String(err)}`);
}
console.info(`Row count after delete: ${rowCountAfter}`);
const otherRowCount = ctx.db.testATable.count();
console.info(`Row count filtered by condition: ${otherRowCount}`);
console.info('MultiColumn');
for (let i = 0; i < 1000; i++) {
ctx.db.points.insert({
x: BigInt(i) + BigInt(arg.x),
y: BigInt(i) + BigInt(arg.y),
});
}
let multiRowCount = 0;
for (const row of ctx.db.points.iter()) {
if (row.x >= 0n && row.y <= 200n) multiRowCount++;
}
console.info(
`Row count filtered by multi-column condition: ${multiRowCount}`
);
console.info('END');
}
);
// add_player(name) -> Result<(), String>
export const add_player = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
const rec = { id: 0n as bigint, name };
const inserted = ctx.db.testE.insert(rec); // id autoInc => always creates a new one
// No-op re-upsert by id index if your bindings support it.
if (ctx.db.testE.id?.update) ctx.db.testE.id.update(inserted);
}
);
// delete_player(id) -> Result<(), String>
export const delete_player = spacetimedb.reducer(
{ id: t.u64() },
(ctx, { id }) => {
const ok = ctx.db.testE.id.delete(id);
if (!ok) throw new Error(`No TestE row with id ${id}`);
}
);
// delete_players_by_name(name) -> Result<(), String>
export const delete_players_by_name = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
let deleted = 0;
for (const row of ctx.db.testE.iter()) {
if (row.name === name) {
if (ctx.db.testE.delete(row)) deleted++;
}
}
if (deleted === 0)
throw new Error(`No TestE row with name ${JSON.stringify(name)}`);
console.info(
`Deleted ${deleted} player(s) with name ${JSON.stringify(name)}`
);
}
);
// client_connected hook
export const clientConnected = spacetimedb.clientConnected(_ctx => {
// no-op
});
// add_private(name)
export const add_private = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
ctx.db.privateTable.insert({ name });
}
);
// query_private()
export const query_private = spacetimedb.reducer(ctx => {
for (const row of ctx.db.privateTable.iter()) {
console.info(`Private, ${row.name}!`);
}
console.info('Private, World!');
});
// test_btree_index_args
// (In Rust this exists to type-check various index argument forms.)
export const test_btree_index_args = spacetimedb.reducer(ctx => {
const s = 'String';
// Demonstrate scanning via iteration; prefer index access if bindings expose it.
for (const row of ctx.db.testE.iter()) {
if (row.name === s || row.name === 'str') {
// no-op; exercising types
}
}
for (const row of ctx.db.points.iter()) {
void row; // exercise multi-column index presence
}
});
// assert_caller_identity_is_module_identity
export const assert_caller_identity_is_module_identity = spacetimedb.reducer(
ctx => {
const caller = ctx.sender;
const owner = ctx.databaseIdentity;
if (String(caller) !== String(owner)) {
throw new Error(`Caller ${caller} is not the owner ${owner}`);
} else {
console.info(`Called by the owner ${owner}`);
}
}
);
// Hit SpacetimeDB's schema HTTP route and return its result as a string.
//
// This is a silly thing to do, but an effective test of the procedure HTTP API.
export const getMySchemaViaHttp = spacetimedb.procedure(t.string(), ctx => {
const module_identity = ctx.databaseIdentity;
try {
const response = ctx.http.fetch(
`http://localhost:3000/v1/database/${module_identity}/schema?version=9`
);
return response.text();
} catch (e) {
if (e instanceof errors.HttpError) {
return e.message;
}
throw e;
}
});
export const getSimple = spacetimedb.httpHandler(
(_ctx, _req) => new SyncResponse('ok')
);
export const router = spacetimedb.httpRouter(
new Router().get('/get', getSimple)
);