This document catalogs inconsistencies across the query builder syntax in SpacetimeDB and proposes a standardization plan. The scope covers:
- Rust server-side (views/module query builder) —
crates/query-builder/ - TypeScript server-side (views/module query builder) —
crates/bindings-typescript/src/lib/query.ts - TypeScript client-side (subscription query builder) — same file, used via codegen'd
queryexport - TypeScript
useTablehook (React) —crates/bindings-typescript/src/lib/filter.ts - Proposals 0030 (Views) and 0031 (Client Query Builder)
On the TypeScript server (views), tables are accessed via ctx.from.person. On the TypeScript client, they're accessed via a standalone codegen'd export called query:
// Server-side view
ctx.from.person.where(row => row.id.eq(5)).build()
// Client-side subscription (current)
import { query } from './module_bindings';
conn.subscriptionBuilder().subscribe(query.player.build());Proposal 0031 intended client-side access to also go through ctx.from:
conn.subscriptionBuilder()
.addQuery(ctx => ctx.from.users.build())
.subscribe();The query export should not exist. The tables export (which already exists as table definitions) should carry query builder methods directly, and the subscription API should support a callback form with ctx.from for cross-language consistency.
The useTable hook uses filter.ts — a separate, simpler system with string-based column names:
// useTable (string-based, different API)
useTable(tableDef, where(eq('online', true)))
// Server-side view (typed, property-based)
ctx.from.myTable.where(row => row.online.eq(true))filter.ts defines Expr<Column> with eq(key, value), and(...), or(...) where keys are strings. The query builder uses typed property accessors via ColumnExpression. These are completely separate codepaths with different capabilities (filter.ts only supports equality).
Every query must end with .build():
// TypeScript
query.player.where(row => row.online.eq(true)).build()
// Rust
ctx.from.user().r#where(|c| c.online.eq(true)).build()In TypeScript, .build() is a no-op cast — FromBuilder and SemijoinImpl already carry the QueryBrand symbol and implement toSql(). The subscribe() method already accepts RowTypedQuery and checks via isRowTypedQuery(). The intermediate builder types already satisfy the query interface.
In Rust, .build() materializes the SQL string. But this could be deferred to when .sql() is actually called, with builder types implementing Into<Query<T>>.
Rust has six comparison operators: eq, ne, gt, lt, gte, lte. TypeScript's ColumnExpression has all of these except ne.
TypeScript has not(expr) as a standalone function. Rust's BoolExpr only has And and Or variants — no Not.
Rust uses method chaining:
c.age.gt(20).and(c.age.lt(30))TypeScript uses standalone functions:
and(row.name.eq('Alice'), row.age.eq(30))These should be standardized on methods for consistency:
row.name.eq('Alice').and(row.age.eq(30))TypeScript exports a from() function that wraps a TableRef in a FromBuilder:
from(qb.person).where(row => row.name.eq('Alice')).build()But TableRefImpl already implements From<TableDef>, so you can call .where() directly:
qb.person.where(row => row.name.eq('Alice')).build()The from() function is redundant and should be deprecated.
Proposal 0031 (Rust) uses addQuery chaining:
ctx.subscription_builder()
.add_query(|ctx| ctx.from.users().build())
.add_query(|ctx| ctx.from.players().build())
.subscribe();This is needed in Rust for per-query type inference and type-state transitions. In TypeScript, arrays are idiomatic and addQuery is unnecessary ceremony:
// Preferred for TypeScript — pass array to subscribe
conn.subscriptionBuilder().subscribe(ctx => [
ctx.from.user.where(r => r.online.eq(true)),
ctx.from.player,
]);Users should not need to call .build() at the end of every query.
TypeScript: The builder types already carry the QueryBrand and implement toSql(). Update the types so From<TableDef> and SemijoinBuilder<TableDef> are assignable to Query<TableDef>. Keep .build() as a deprecated no-op for backwards compatibility.
Rust: Implementation approach TBD (e.g. Into<Query<T>>, a custom trait, or macro-level changes). The #[view] macro should accept builder types directly, not just Query<T>.
After:
// TypeScript
conn.subscriptionBuilder().subscribe(tables.user.where(r => r.online.eq(true)));
// Rust
ctx.from.user().r#where(|c| c.online.eq(true)) // returned directly from viewThe codegen'd query export should be removed. The existing tables export should carry query builder capabilities (.where(), .leftSemijoin(), .rightSemijoin(), etc.) directly on each table.
The subscribe() method should accept a callback that receives a query context, matching cross-language consistency with Rust and C#:
// Callback form (canonical, cross-language consistent)
conn.subscriptionBuilder().subscribe(ctx => ctx.from.user.where(r => r.online.eq(true)));
// Array form
conn.subscriptionBuilder().subscribe(ctx => [
ctx.from.user.where(r => r.online.eq(true)),
ctx.from.player,
]);
// Direct expression form (also accepted, convenient shorthand)
conn.subscriptionBuilder().subscribe(tables.user.where(r => r.online.eq(true)));The ctx mirrors whatever the Rust query context carries (currently from, potentially identity/connection info for parameterized views in the future).
No addQuery() chaining in TypeScript — pass single queries or arrays directly to subscribe().
Replace the string-based filter.ts system with the typed query builder:
Before:
useTable(tableDef, where(eq('online', true)))After:
useTable(tables.user.where(row => row.online.eq(true)))
// or without a filter:
useTable(tables.user)
// with callbacks:
useTable(tables.user.where(row => row.online.eq(true)), {
onInsert: (row) => console.log('Inserted:', row),
})This deprecates filter.ts (eq, and, or, where from that module) in favor of the query builder's typed expressions. Client-side evaluation for useTable will need to work with BooleanExpr instead of Expr.
Add ne() to ColumnExpression in query.ts, following the exact same pattern as eq, lt, gt, etc.
Add a Not(Box<BoolExpr<T>>) variant to BoolExpr<T> in expr.rs, a .not() method on BoolExpr, and handle it in format_expr.
TypeScript should support method-style and/or on boolean expressions to match Rust:
// Target (method style, consistent with Rust)
row.age.gt(20).and(row.age.lt(30))
// Still supported (standalone functions)
and(row.age.gt(20), row.age.lt(30))This means BooleanExpr in TypeScript needs .and() and .or() methods. The standalone and()/or() functions can remain as convenience.
Mark from() as deprecated. All docs and examples should use the table ref directly:
// Before
from(tables.person).where(row => row.name.eq('Alice'))
// After
tables.person.where(row => row.name.eq('Alice'))#[spacetimedb::view(accessor = online_users, public)]
fn online_users(ctx: &ViewContext) -> Query<User> {
ctx.from.user().r#where(|c| c.online.eq(true))
}
#[spacetimedb::view(accessor = player_mods, public)]
fn player_mods(ctx: &AnonymousViewContext) -> Query<PlayerState> {
ctx.from
.player_state()
.left_semijoin(ctx.from.moderator(), |p, m| p.entity_id.eq(m.entity_id))
}ctx.subscription_builder()
.add_query(|ctx| ctx.from.user().r#where(|c| c.online.eq(true)))
.add_query(|ctx| ctx.from.player())
.subscribe();spacetime.anonymousView({ name: 'onlineUsers', public: true }, arrayRetValue, ctx => {
return ctx.from.user.where(row => row.online.eq(true));
});// Callback form (cross-language consistent)
conn.subscriptionBuilder().subscribe(ctx => [
ctx.from.user.where(r => r.online.eq(true)),
ctx.from.player,
]);
// Direct form (convenient shorthand)
conn.subscriptionBuilder().subscribe(tables.user.where(r => r.online.eq(true)));const [users, isReady] = useTable(tables.user.where(row => row.online.eq(true)));
const [allPlayers, isReady] = useTable(tables.player);
const [users, isReady] = useTable(tables.user.where(row => row.online.eq(true)), {
onInsert: (row) => console.log('New user:', row),
});| Component | Path |
|---|---|
| Rust query builder core | crates/query-builder/src/{lib,table,join,expr}.rs |
Rust #[table] macro codegen |
crates/bindings-macro/src/table.rs |
| Rust client SDK codegen | crates/codegen/src/rust.rs |
| Rust view context | crates/bindings/src/lib.rs |
| Rust views smoketest | crates/smoketests/modules/views-query/src/lib.rs |
| TS query builder | crates/bindings-typescript/src/lib/query.ts |
| TS filter (to deprecate) | crates/bindings-typescript/src/lib/filter.ts |
TS React useTable |
crates/bindings-typescript/src/react/useTable.ts |
| TS subscription builder | crates/bindings-typescript/src/sdk/subscription_builder_impl.ts |
| TS codegen | crates/codegen/src/typescript.rs |
| TS view type tests | crates/bindings-typescript/src/server/view.test-d.ts |
| TS query builder tests | crates/bindings-typescript/tests/query.test.ts |
| TS client query tests | crates/bindings-typescript/tests/client_query.test.ts |