diff --git a/crates/bindings-typescript/README.md b/crates/bindings-typescript/README.md index 6012b11da..69b3ef700 100644 --- a/crates/bindings-typescript/README.md +++ b/crates/bindings-typescript/README.md @@ -21,7 +21,7 @@ You can use the package in the browser, using a bundler like vite/parcel/rsbuild In order to connect to a database you have to generate module bindings for your database. ```ts -import { DbConnection } from './module_bindings'; +import { DbConnection, tables } from './module_bindings'; const connection = DbConnection.builder() .withUri('ws://localhost:3000') @@ -38,7 +38,7 @@ const connection = DbConnection.builder() identity.toHexString() ); - connection.subscriptionBuilder().subscribe('SELECT * FROM player'); + connection.subscriptionBuilder().subscribe(tables.player); }) .withToken('TOKEN') .build(); @@ -85,7 +85,7 @@ const connectionBuilder = DbConnection.builder() identity.toHexString() ); - conn.subscriptionBuilder().subscribe('SELECT * FROM player'); + conn.subscriptionBuilder().subscribe(tables.player); }) .withToken('TOKEN'); diff --git a/crates/smoketests/tests/views.rs b/crates/smoketests/tests/views.rs index b7bab8969..1a0070083 100644 --- a/crates/smoketests/tests/views.rs +++ b/crates/smoketests/tests/views.rs @@ -20,6 +20,12 @@ export const my_player = spacetimedb.view( ctx => ctx.db.playerState.identity.find(ctx.sender) ); +export const all_players = spacetimedb.anonymousView( + { public: true }, + t.array(playerState.rowType), + ctx => ctx.from.playerState +); + export const insert_player_proc = spacetimedb.procedure( { name: t.string() }, t.unit(), @@ -510,3 +516,24 @@ fn test_typescript_procedure_triggers_subscription_updates() { ]) ); } + +#[test] +fn test_typescript_query_builder_view_query() { + require_pnpm!(); + let mut test = Smoketest::builder().autopublish(false).build(); + test.publish_typescript_module_source( + "views-subscribe-typescript", + "views-subscribe-typescript", + TS_VIEWS_SUBSCRIBE_MODULE, + ) + .unwrap(); + + test.call("insert_player_proc", &["Alice"]).unwrap(); + + test.assert_sql( + "SELECT name FROM all_players", + r#" name +--------- + "Alice""#, + ); +} diff --git a/docs/docs/00100-intro/00100-getting-started/00100-getting-started.md b/docs/docs/00100-intro/00100-getting-started/00100-getting-started.md index 0a3424c87..2f0e9fe86 100644 --- a/docs/docs/00100-intro/00100-getting-started/00100-getting-started.md +++ b/docs/docs/00100-intro/00100-getting-started/00100-getting-started.md @@ -53,5 +53,5 @@ After completing a quickstart guide, explore these core concepts to deepen your - **[Databases](/databases)** - Understand database lifecycle, publishing, and management - **[Tables](/tables)** - Define your data structure with tables, columns, and indexes - **[Functions](/functions)** - Write reducers, procedures, and views to implement your server logic -- **[Subscriptions](/subscriptions)** - Enable real-time data synchronization with clients -- **[Client SDKs](/sdks)** - Connect your client applications to SpacetimeDB +- **[Subscriptions](/clients/subscriptions)** - Enable real-time data synchronization with clients +- **[Client SDKs](/clients)** - Connect your client applications to SpacetimeDB diff --git a/docs/docs/00100-intro/00100-getting-started/00200-what-is-spacetimedb.md b/docs/docs/00100-intro/00100-getting-started/00200-what-is-spacetimedb.md index 9285add7c..8f88afe55 100644 --- a/docs/docs/00100-intro/00100-getting-started/00200-what-is-spacetimedb.md +++ b/docs/docs/00100-intro/00100-getting-started/00200-what-is-spacetimedb.md @@ -58,4 +58,4 @@ The above illustrates the workflow when using SpacetimeDB. SpacetimeDB can generate client code in a [variety of languages](/intro/language-support). This creates a client library custom-designed to talk to your database. It provides easy-to-use interfaces for connecting to the database and submitting requests. It can also **automatically mirror state** from your database to client applications. -You write SQL queries specifying what information a client is interested in -- for instance, the terrain and items near a player's avatar. SpacetimeDB will generate types in your client language for the relevant tables, and feed clients a stream of live updates whenever the database state changes. Note that this is a **read-only** mirror -- the only way to change the database is to submit requests, which are validated on the server. +You define subscriptions specifying what information a client is interested in, typically with the type-safe query builder (or raw SQL for advanced cases) -- for instance, the terrain and items near a player's avatar. SpacetimeDB will generate types in your client language for the relevant tables, and feed clients a stream of live updates whenever the database state changes. Note that this is a **read-only** mirror -- the only way to change the database is to submit requests, which are validated on the server. diff --git a/docs/docs/00100-intro/00100-getting-started/00300-language-support.md b/docs/docs/00100-intro/00100-getting-started/00300-language-support.md index ab5cacbab..5f274f947 100644 --- a/docs/docs/00100-intro/00100-getting-started/00300-language-support.md +++ b/docs/docs/00100-intro/00100-getting-started/00300-language-support.md @@ -16,10 +16,10 @@ SpacetimeDB modules define your database schema and server-side business logic. **Clients** are applications that connect to SpacetimeDB databases. The `spacetime` CLI tool can automatically generate type-safe client code for your database. -- **[Rust](/sdks/rust)** - [(Quickstart)](/quickstarts/rust) -- **[C#](/sdks/c-sharp)** - [(Quickstart)](/quickstarts/c-sharp) -- **[TypeScript](/sdks/typescript)** - [(Quickstart)](/quickstarts/typescript) -- **[Unreal Engine](/sdks/unreal)** - C++ and Blueprint support [(Tutorial)](/tutorials/unreal/part-1) +- **[Rust](/clients/rust)** - [(Quickstart)](/quickstarts/rust) +- **[C#](/clients/c-sharp)** - [(Quickstart)](/quickstarts/c-sharp) +- **[TypeScript](/clients/typescript)** - [(Quickstart)](/quickstarts/typescript) +- **[Unreal Engine](/clients/unreal)** - C++ and Blueprint support [(Tutorial)](/tutorials/unreal/part-1) ### Unity diff --git a/docs/docs/00100-intro/00100-getting-started/00400-key-architecture.md b/docs/docs/00100-intro/00100-getting-started/00400-key-architecture.md index a92ed1603..4d04077d3 100644 --- a/docs/docs/00100-intro/00100-getting-started/00400-key-architecture.md +++ b/docs/docs/00100-intro/00100-getting-started/00400-key-architecture.md @@ -99,7 +99,7 @@ Tables marked `public` can also be read by [clients](#client). ## Reducer A **reducer** is a function exported by a [database](#database). -Connected [clients](/sdks) can call reducers to interact with the database. +Connected [clients](/clients) can call reducers to interact with the database. This is a form of [remote procedure call](https://en.wikipedia.org/wiki/Remote_procedure_call). @@ -556,7 +556,7 @@ See [Views](/functions/views) for more details about views. A **client** is an application that connects to a [database](#database). A client logs in using an [identity](#identity) and receives an [connection id](#connectionid) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). -Clients are written using the [client-side SDKs](/sdks). The `spacetime` CLI tool allows automatically generating code that works with the client-side SDKs to talk to a particular database. +Clients are written using the [client-side SDKs](/clients). The `spacetime` CLI tool allows automatically generating code that works with the client-side SDKs to talk to a particular database. Clients are regular software applications that developers can choose how to deploy (through Steam, app stores, package managers, or any other software deployment method, depending on the needs of the application.) diff --git a/docs/docs/00100-intro/00100-getting-started/00500-faq.md b/docs/docs/00100-intro/00100-getting-started/00500-faq.md index 456e281d7..99eb3de17 100644 --- a/docs/docs/00100-intro/00100-getting-started/00500-faq.md +++ b/docs/docs/00100-intro/00100-getting-started/00500-faq.md @@ -102,14 +102,16 @@ SpacetimeDB holds all data in memory for fast access, but persists everything to ### How does real-time sync work? -Clients subscribe to SQL queries that specify what data they care about. SpacetimeDB evaluates these subscriptions and pushes incremental updates whenever the underlying data changes. On the client side, the SDK maintains a local cache that mirrors the server state. You query this cache directly with no round trips. +Clients subscribe to typed query builder expressions that specify what data they care about. (Raw SQL subscriptions are also available for advanced use cases.) SpacetimeDB evaluates these subscriptions and pushes incremental updates whenever the underlying data changes. On the client side, the SDK maintains a local cache that mirrors the server state. You query this cache directly with no round trips. ```typescript -// Client subscribes using the type-safe query builder +// Subscribe to all players conn.subscriptionBuilder().subscribe(tables.players); -// Or with a raw SQL query -conn.subscriptionBuilder().subscribe("SELECT * FROM players WHERE team = 'red'"); +// Subscribe to a filtered subset +conn.subscriptionBuilder().subscribe( + tables.players.where(p => p.team.eq('red')) +); // The local cache updates automatically const players = ctx.db.players; @@ -160,7 +162,7 @@ See the [quickstart guides](/quickstarts/react) for step-by-step tutorials. ### Do I need to write SQL? -Only for client-side subscriptions (telling the client what data to sync). Your server-side module code uses native language APIs to query and modify tables. No SQL is needed on the server side. On the client side, you can also use the type-safe query builder instead of raw SQL. +No. Your server-side module code uses native language APIs to query and modify tables. For client-side subscriptions, the type-safe query builder is the recommended default. Raw SQL subscriptions are optional for advanced cases. ### Can I use SpacetimeDB with Unity? @@ -205,11 +207,11 @@ Identities issued by SpacetimeDB acting as its own identity provider are tied to ### How do I subscribe to data related to a specific `Identity`? -Write a subscription query that filters by the identity's hex representation: +Build a typed subscription query that filters by the caller identity: ```typescript conn.subscriptionBuilder().subscribe( - `SELECT * FROM messages WHERE sender = 0x${identityHex}` + tables.messages.where(m => m.sender.eq(identity)) ); ``` diff --git a/docs/docs/00100-intro/00200-quickstarts/00100-react.md b/docs/docs/00100-intro/00200-quickstarts/00100-react.md index 19959850f..640fe20ed 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00100-react.md +++ b/docs/docs/00100-intro/00200-quickstarts/00100-react.md @@ -128,4 +128,4 @@ spacetime logs ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00150-nextjs.md b/docs/docs/00100-intro/00200-quickstarts/00150-nextjs.md index bc2744d57..b9ef11fea 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00150-nextjs.md +++ b/docs/docs/00100-intro/00200-quickstarts/00150-nextjs.md @@ -139,7 +139,7 @@ spacetime logs my-nextjs-app ```tsx // lib/spacetimedb-server.ts -import { DbConnection } from '../src/module_bindings'; +import { DbConnection, tables } from '../src/module_bindings'; export async function fetchPeople() { return new Promise((resolve, reject) => { @@ -153,7 +153,7 @@ export async function fetchPeople() { conn.disconnect(); resolve(people); }) - .subscribe('SELECT * FROM person'); + .subscribe(tables.person); }) .build(); }); @@ -207,4 +207,4 @@ export function PersonList({ initialPeople }) { ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00150-vue.md b/docs/docs/00100-intro/00200-quickstarts/00150-vue.md index ac42726fd..20fbcd2ae 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00150-vue.md +++ b/docs/docs/00100-intro/00200-quickstarts/00150-vue.md @@ -125,4 +125,4 @@ spacetime logs ## Next steps -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00155-nuxt.md b/docs/docs/00100-intro/00200-quickstarts/00155-nuxt.md index dc8065d68..31d0cfdc3 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00155-nuxt.md +++ b/docs/docs/00100-intro/00200-quickstarts/00155-nuxt.md @@ -138,7 +138,7 @@ spacetime logs ```typescript // server/api/people.get.ts -import { DbConnection } from '../../module_bindings'; +import { DbConnection, tables } from '../../module_bindings'; export default defineEventHandler(async () => { return new Promise((resolve, reject) => { @@ -152,7 +152,7 @@ export default defineEventHandler(async () => { conn.disconnect(); resolve(people); }) - .subscribe('SELECT * FROM person'); + .subscribe(tables.person); }) .build(); }); @@ -240,4 +240,4 @@ const displayPeople = computed(() => { ## Next steps -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00160-svelte.md b/docs/docs/00100-intro/00200-quickstarts/00160-svelte.md index 510a90058..9e16d418c 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00160-svelte.md +++ b/docs/docs/00100-intro/00200-quickstarts/00160-svelte.md @@ -125,4 +125,4 @@ spacetime logs ## Next steps -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00165-angular.md b/docs/docs/00100-intro/00200-quickstarts/00165-angular.md index e89b3c1b8..69592d184 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00165-angular.md +++ b/docs/docs/00100-intro/00200-quickstarts/00165-angular.md @@ -127,4 +127,4 @@ spacetime logs ## Next steps -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00170-tanstack.md b/docs/docs/00100-intro/00200-quickstarts/00170-tanstack.md index 841691070..aaef274cf 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00170-tanstack.md +++ b/docs/docs/00100-intro/00200-quickstarts/00170-tanstack.md @@ -163,4 +163,4 @@ function App() { ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00175-remix.md b/docs/docs/00100-intro/00200-quickstarts/00175-remix.md index 01aecf044..b089224c1 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00175-remix.md +++ b/docs/docs/00100-intro/00200-quickstarts/00175-remix.md @@ -138,7 +138,7 @@ spacetime logs my-remix-app ```tsx // app/lib/spacetimedb.server.ts -import { DbConnection } from '../../src/module_bindings'; +import { DbConnection, tables } from '../../src/module_bindings'; export async function fetchPeople() { return new Promise((resolve, reject) => { @@ -152,7 +152,7 @@ export async function fetchPeople() { conn.disconnect(); resolve(people); }) - .subscribe('SELECT * FROM person'); + .subscribe(tables.person); }) .build(); }); @@ -202,4 +202,4 @@ export default function Index() { ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00180-browser.md b/docs/docs/00100-intro/00200-quickstarts/00180-browser.md index 649f7e2ea..b8151e4bb 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00180-browser.md +++ b/docs/docs/00100-intro/00200-quickstarts/00180-browser.md @@ -54,7 +54,7 @@ npm run build The JavaScript code runs inline in a script tag, using the bundled `DbConnection` class. :::tip - When using npm imports with a bundler (e.g. React, Vue, Svelte), you can use type-safe [query builders](/sdks/typescript#query-builder-api) instead of raw SQL strings. The IIFE bundle shown here uses raw SQL. + The browser IIFE bundle also exposes the generated `tables` query builders, so you can use query-builder subscriptions here too. ::: @@ -82,7 +82,7 @@ npm run build console.log(person.name); } }) - .subscribe(['SELECT * FROM person']); + .subscribe(tables.person); }) .build(); @@ -123,4 +123,4 @@ conn.db.person.onDelete((ctx, person) => { ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00250-bun.md b/docs/docs/00100-intro/00200-quickstarts/00250-bun.md index f40c1cc57..94c2f2a60 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00250-bun.md +++ b/docs/docs/00100-intro/00200-quickstarts/00250-bun.md @@ -260,4 +260,4 @@ bun run start ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00275-deno.md b/docs/docs/00100-intro/00200-quickstarts/00275-deno.md index 2bc5065eb..88ff46efb 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00275-deno.md +++ b/docs/docs/00100-intro/00200-quickstarts/00275-deno.md @@ -272,4 +272,4 @@ cat deno.json ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00300-nodejs.md b/docs/docs/00100-intro/00200-quickstarts/00300-nodejs.md index e26004d86..b62cdf52c 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00300-nodejs.md +++ b/docs/docs/00100-intro/00200-quickstarts/00300-nodejs.md @@ -255,4 +255,4 @@ npm run start ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00400-typescript.md b/docs/docs/00100-intro/00200-quickstarts/00400-typescript.md index be9e909e3..9bec287b8 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00400-typescript.md +++ b/docs/docs/00100-intro/00200-quickstarts/00400-typescript.md @@ -119,4 +119,4 @@ spacetime logs ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs +- Read the [TypeScript SDK Reference](/clients/typescript) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00500-rust.md b/docs/docs/00100-intro/00200-quickstarts/00500-rust.md index 516077dc9..6c9c09598 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00500-rust.md +++ b/docs/docs/00100-intro/00200-quickstarts/00500-rust.md @@ -118,4 +118,4 @@ spacetime logs my-spacetime-app ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [Rust SDK Reference](/sdks/rust) for detailed API docs +- Read the [Rust SDK Reference](/clients/rust) for detailed API docs diff --git a/docs/docs/00100-intro/00200-quickstarts/00600-c-sharp.md b/docs/docs/00100-intro/00200-quickstarts/00600-c-sharp.md index a3b7ee362..d8ea98463 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00600-c-sharp.md +++ b/docs/docs/00100-intro/00200-quickstarts/00600-c-sharp.md @@ -134,4 +134,4 @@ spacetime logs ## Next steps - See the [Chat App Tutorial](/tutorials/chat-app) for a complete example -- Read the [C# SDK Reference](/sdks/c-sharp) for detailed API docs +- Read the [C# SDK Reference](/clients/c-sharp) for detailed API docs diff --git a/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md b/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md index 1c29ff4f0..8281ab4b1 100644 --- a/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md +++ b/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md @@ -2101,13 +2101,11 @@ void Reducer_OnSendMessageEvent(ReducerEventContext ctx, string text) ### Subscribe to queries -SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database using `SubscribeToAllTables`. - -You can also subscribe to specific tables using SQL syntax, e.g. `SELECT * FROM my_table`. Our [SQL documentation](/reference/sql) enumerates the operations that are accepted in our SQL syntax. +SpacetimeDB is set up so that each client subscribes to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database using `SubscribeToAllTables`. When we specify our subscriptions, we can supply an `OnApplied` callback. This will run when the subscription is applied and the matching rows become available in our client cache. We'll use this opportunity to print the message backlog in proper order. -We can also provide an `OnError` callback. This will run if the subscription fails, usually due to an invalid or malformed SQL queries. We can't handle this case, so we'll just print out the error and exit the process. +We can also provide an `OnError` callback. With query-builder subscriptions, invalid query shapes are caught by the type system, so this callback is less likely to fire due to query construction mistakes. (With raw SQL subscriptions, invalid query text can fail at runtime.) We can't handle this case here, so we'll just print out the error and exit the process. In `Program.cs`, update our `OnConnected` function to include `conn.SubscriptionBuilder().OnApplied(OnSubscriptionApplied).SubscribeToAllTables();` so that it reads: @@ -2347,7 +2345,7 @@ Our `main` function will do the following: 1. Connect to the database. 2. Register a number of callbacks to run in response to various database events. -3. Subscribe to a set of SQL queries, whose results will be replicated and automatically updated in our client. +3. Subscribe to a set of queries, whose results will be replicated and automatically updated in our client. 4. Spawn a background thread where our connection will process messages and invoke callbacks. 5. Enter a loop to handle user input from the command line. @@ -2361,7 +2359,7 @@ fn main() { // Register callbacks to run in response to database events. register_callbacks(&ctx); - // Subscribe to SQL queries in order to construct a local partial replica of the database. + // Subscribe to queries in order to construct a local partial replica of the database. subscribe_to_tables(&ctx); // Spawn a thread, where the connection will process messages and invoke callbacks. @@ -2590,11 +2588,11 @@ fn print_message(ctx: &impl RemoteDbContext, message: &Message) { ### Subscribe to queries -SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. +SpacetimeDB is set up so that each client subscribes to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. When we specify our subscriptions, we can supply an `on_applied` callback. This will run when the subscription is applied and the matching rows become available in our client cache. We'll use this opportunity to print the message backlog in proper order. -We'll also provide an `on_error` callback. This will run if the subscription fails, usually due to an invalid or malformed SQL queries. We can't handle this case, so we'll just print out the error and exit the process. +We'll also provide an `on_error` callback. This will run if the subscription fails for any reason, such as for an invalid or malformed query. These errors are less likely when using the query builder since invalid query shapes are caught by the type system. They are more likely to occur when using raw SQL. To `src/main.rs`, add: @@ -2604,7 +2602,9 @@ fn subscribe_to_tables(ctx: &DbConnection) { ctx.subscription_builder() .on_applied(on_sub_applied) .on_error(on_sub_error) - .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); + .add_query(|q| q.from.user()) + .add_query(|q| q.from.message()) + .subscribe(); } ``` @@ -2632,9 +2632,7 @@ fn on_sub_applied(ctx: &SubscriptionEventContext) { #### Notify about failed subscriptions -It's possible for SpacetimeDB to reject subscriptions. This happens most often because of a typo in the SQL queries, but can be due to use of SQL features that SpacetimeDB doesn't support. See [SQL Support: Subscriptions](/reference/sql#subscriptions) for more information about what subscription queries SpacetimeDB supports. - -In our case, we're pretty confident that our queries are valid, but if SpacetimeDB rejects them, we want to know about it. Our callback will print the error, then exit the process. +It's possible for SpacetimeDB to reject subscriptions. With raw SQL subscriptions, this often happens due to invalid query text. In our case, because we're using the query builder, we can be confident our queries are valid unless the database we're connecting to has changed. If SpacetimeDB rejects them, our callback will print the error, then exit the process. ```rust /// Or `on_error` callback: @@ -2735,6 +2733,6 @@ User connected. Congratulations! You've built a chat app with SpacetimeDB. -- Check out the [SDK Reference documentation](/sdks) for more advanced usage +- Check out the [SDK Reference documentation](/clients) for more advanced usage - Explore the [Unity Tutorial](/docs/tutorials/unity) or [Unreal Tutorial](/docs/tutorials/unreal) for game development - Learn about [Procedures](/functions/procedures) for making external API calls diff --git a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md index 6fda936ec..243108876 100644 --- a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md +++ b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md @@ -798,7 +798,7 @@ public class GameManager : MonoBehaviour Here we configure the connection to the database, by passing it some callbacks in addition to providing the `SERVER_URI` and `MODULE_NAME` to the connection. When the client connects, the SpacetimeDB SDK will call the `HandleConnect` method, allowing us to start up the game. -In our `HandleConnect` callback we build a subscription and are calling `Subscribe` and subscribing to all data in the database. This will cause SpacetimeDB to synchronize the state of all your tables with your Unity client's SpacetimeDB SDK's "client cache". You can also subscribe to specific tables using SQL syntax, e.g. `SELECT * FROM my_table`. Our [SQL documentation](/reference/sql) enumerates the operations that are accepted in our SQL syntax. +In our `HandleConnect` callback we build a subscription and call `Subscribe`, subscribing to all data in the database. This causes SpacetimeDB to synchronize the state of all your tables with your Unity client's SDK client cache. --- diff --git a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00200-part-1.md b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00200-part-1.md index 6e3d077fe..3f99a1ea2 100644 --- a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00200-part-1.md +++ b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00200-part-1.md @@ -37,7 +37,7 @@ Click **Create** to generate the blank project. While the SpacetimeDB Unreal client SDK is in preview releases, it can only be installed from GitHub: -> [https://github.com/clockworklabs/SpacetimeDB/tree/v1.12.0/sdks/unreal/src](https://github.com/clockworklabs/SpacetimeDB/tree/v1.12.0/sdks/unreal/src) +> [https://github.com/clockworklabs/SpacetimeDB/tree/v1.12.0/clients/unreal/src](https://github.com/clockworklabs/SpacetimeDB/tree/v1.12.0/clients/unreal/src) Once the SDK is stabilized, we'll find a more ergonomic way to distribute it. diff --git a/docs/docs/00200-core-concepts/00000-index.md b/docs/docs/00200-core-concepts/00000-index.md index f9e4dc681..3ad49c6a0 100644 --- a/docs/docs/00200-core-concepts/00000-index.md +++ b/docs/docs/00200-core-concepts/00000-index.md @@ -32,13 +32,6 @@ Implement your application logic with reducers, procedures, and views. - [Procedures](/functions/procedures) - Functions that can make external HTTP calls - [Views](/functions/views) - Read-only computed queries -## Subscriptions - -Enable real-time updates for your clients. - -- [Subscription Reference](/subscriptions) - How to subscribe to data -- [Subscription Semantics](/subscriptions/semantics) - Understanding update guarantees - ## Authentication Secure your application with SpacetimeAuth. @@ -47,10 +40,14 @@ Secure your application with SpacetimeAuth. - [SpacetimeAuth Overview](./00500-authentication/00100-spacetimeauth/index.md) - Managed authentication service - [Auth Claims](./00500-authentication/00500-usage.md) - Using identity and roles -## Client SDKs +## Clients Connect your frontend to SpacetimeDB. -- [SDK Overview](/sdks) - Available client SDKs -- [Code Generation](/sdks/codegen) - Generate type-safe bindings -- [TypeScript](/sdks/typescript), [Rust](/sdks/rust), [C#](/sdks/c-sharp), [Unreal](/sdks/unreal) - Language-specific references +- [SDK Overview](/clients) - Available client SDKs +- [Code Generation](/clients/codegen) - Generate type-safe bindings +- [Connecting to SpacetimeDB](/clients/connection) - Establish and manage client connections +- [SDK API Overview](/clients/api) - Core API concepts shared across SDKs +- [Subscriptions](/clients/subscriptions) - Subscribe to data and keep a local cache in sync +- [Subscription Semantics](/clients/subscriptions/semantics) - Understand subscription consistency and ordering guarantees +- [TypeScript](/clients/typescript), [Rust](/clients/rust), [C#](/clients/c-sharp), [Unreal](/clients/unreal) - Language-specific references diff --git a/docs/docs/00200-core-concepts/00100-databases.md b/docs/docs/00200-core-concepts/00100-databases.md index 5389b18fc..7bd2a5a91 100644 --- a/docs/docs/00200-core-concepts/00100-databases.md +++ b/docs/docs/00200-core-concepts/00100-databases.md @@ -210,7 +210,7 @@ If you're new to SpacetimeDB, follow this recommended learning path: 2. **[Build and Publish](/databases/building-publishing)** - Learn how to compile and deploy your module 3. **[Define Tables](/tables)** - Structure your data with tables, columns, and indexes 4. **[Write Reducers](/functions/reducers)** - Create transactional functions that modify your database -5. **[Connect a Client](/sdks)** - Build a client application that connects to your database +5. **[Connect a Client](/clients)** - Build a client application that connects to your database ### Core Concepts @@ -242,5 +242,5 @@ When you're ready to go live: - Learn about [Tables](/tables) to define your database schema - Create [Reducers](/functions/reducers) to modify database state -- Understand [Subscriptions](/subscriptions) for real-time data sync +- Understand [Subscriptions](/clients/subscriptions) for real-time data sync - Review the [CLI Reference](/cli-reference) for all available commands diff --git a/docs/docs/00200-core-concepts/00100-databases/00100-transactions-atomicity.md b/docs/docs/00200-core-concepts/00100-databases/00100-transactions-atomicity.md index 3b3731a39..57f1b32ce 100644 --- a/docs/docs/00200-core-concepts/00100-databases/00100-transactions-atomicity.md +++ b/docs/docs/00200-core-concepts/00100-databases/00100-transactions-atomicity.md @@ -240,4 +240,4 @@ The `#[auto_inc]` sequence generator is not transactional: - **[Reducers](/functions/reducers)** - Functions that modify database state transactionally - **[Procedures](/functions/procedures)** - Functions with manual transaction control - **[Schedule Tables](/tables/schedule-tables)** - Schedule reducers for separate transactions -- **[Subscriptions](/subscriptions)** - How clients receive transactional updates +- **[Subscriptions](/clients/subscriptions)** - How clients receive transactional updates diff --git a/docs/docs/00200-core-concepts/00100-databases/00300-spacetime-publish.md b/docs/docs/00200-core-concepts/00100-databases/00300-spacetime-publish.md index fcdfb2b0c..0342aafac 100644 --- a/docs/docs/00200-core-concepts/00100-databases/00300-spacetime-publish.md +++ b/docs/docs/00200-core-concepts/00100-databases/00300-spacetime-publish.md @@ -108,5 +108,5 @@ For all available publishing options and flags, see the [`spacetime publish` CLI After publishing: -- Learn about [connecting a client](/sdks) to your database +- Learn about [connecting a client](/clients) to your database - Learn about [Tables](/tables), [Reducers](/functions/reducers), and [Procedures](/functions/procedures) diff --git a/docs/docs/00200-core-concepts/00100-databases/00500-migrations/00200-automatic-migrations.md b/docs/docs/00200-core-concepts/00100-databases/00500-migrations/00200-automatic-migrations.md index 8cd550352..3f3e3eea0 100644 --- a/docs/docs/00200-core-concepts/00100-databases/00500-migrations/00200-automatic-migrations.md +++ b/docs/docs/00200-core-concepts/00100-databases/00500-migrations/00200-automatic-migrations.md @@ -35,10 +35,11 @@ These changes are allowed by automatic migration, but may cause runtime errors f - **Removing `Primary Key` annotations.** Non-updated clients will still use the old primary key as a unique key in their local cache, which can result in non-deterministic behavior when updates are received. - **Removing indexes.** This is only breaking in specific situations. The main issue occurs with subscription queries involving semijoins, such as: - ```sql - SELECT Employee.* - FROM Employee JOIN Dept - ON Employee.DeptName = Dept.DeptName + ```typescript + tables.employee.leftSemijoin( + tables.dept, + (employee, dept) => employee.deptName.eq(dept.deptName) + ) ``` For performance reasons, SpacetimeDB will only allow this kind of subscription query if there are indexes on both join columns (`Employee.DeptName` and `Dept.DeptName`). Removing either index will invalidate this subscription query, resulting in client-side runtime errors. diff --git a/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00300-reducers.md b/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00300-reducers.md index 0492996d9..908fcc56c 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00300-reducers.md +++ b/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00300-reducers.md @@ -707,4 +707,4 @@ See [Schedule Tables](/tables/schedule-tables) for more scheduling options. - Learn about [Tables](/tables) to understand data storage - Explore [Procedures](/functions/procedures) for side effects beyond the database -- Review [Subscriptions](/subscriptions) for real-time client updates +- Review [Subscriptions](/clients/subscriptions) for real-time client updates diff --git a/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md b/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md index 807af3531..6134ee83f 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md +++ b/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md @@ -9,7 +9,7 @@ import { CppModuleVersionNotice } from "@site/src/components/CppModuleVersionNot A **procedure** is a function exported by a [database](/databases), similar to a [reducer](/functions/reducers). -Connected [clients](/sdks) can call procedures. +Connected [clients](/clients) can call procedures. Procedures can perform additional operations not possible in reducers, including making HTTP requests to external services. However, procedures don't automatically run in database transactions, and must manually open and commit a transaction in order to read from or modify the database state. diff --git a/docs/docs/00200-core-concepts/00200-functions/00500-views.md b/docs/docs/00200-core-concepts/00200-functions/00500-views.md index f2b99642a..418309df2 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00500-views.md +++ b/docs/docs/00200-core-concepts/00200-functions/00500-views.md @@ -754,7 +754,7 @@ If you need to aggregate or sort entire tables, consider returning a `Query` fro ## Performance Considerations -Views compute results on the server side, which can improve performance by: +Views compute results server side, which can improve performance by: - Reducing network traffic by filtering/aggregating before sending data - Avoiding redundant computation on multiple clients @@ -762,10 +762,470 @@ Views compute results on the server side, which can improve performance by: However, keep in mind that: -- Complex views with multiple joins can be expensive to compute -- Views are recomputed when rows in their read set change -- Subscriptions to views will receive updates even if the final result doesn't change +- View functions are reevaluated when SpacetimeDB detects something they read has since changed (the read set) +- The key to optimizing a view function is keeping its read set small +- View functions can have large read sets when they read from non-unique indexes and/or multiple tables without using the query builder +- View functions are executed largely as written by the WASM/V8 runtimes with little room for global optimization +- Join-heavy view functions will incur extra row materialization costs when serializing data across the WASM/V8 boundary + +If a view is primarily performing query logic like filtering and joining rows, it is recommended to use the module-side query builder, +since it pushes work into the query engine, which can optimize and evaluate the plan more efficiently. + +## Query Builder in Views + +### Example + + + + +```typescript +export const high_scorers = spacetimedb.anonymousView( + { name: 'high_scorers', public: true }, + t.array(players.rowType), + (ctx) => { + return ctx.from.players + .where(p => p.score.gte(1000n)) + .where(p => p.name.ne('BOT')); + } +); +``` + + + + +```csharp +[SpacetimeDB.View(Accessor = "HighScorers", Public = true)] +public static IQuery HighScorers(AnonymousViewContext ctx) +{ + return ctx.From.Player() + .Where(p => p.Score.Gte(1000UL)) + .Where(p => p.Name.Neq("BOT")); +} +``` + + + + +```rust +#[view(accessor = high_scorers, public)] +fn high_scorers(ctx: &AnonymousViewContext) -> impl Query { + ctx.from + .player() + .r#where(|p| p.score.gte(1000u64)) + .r#where(|p| p.name.ne("BOT")) +} +``` + + + + +### Why Use the Query Builder? + +Both `ViewContext` and `AnonymousViewContext` expose a builder API for expressing query logic like filters and joins. +The module-side query builder is designed for view logic that is mostly filters and joins. +These views are evaluated by the query engine instead of the WASM/V8 runtime, the benefits of which include the following: + +- The query engine can apply global optimizations that WASM and V8 cannot, drastically improving the performance of your views +- These views can be updated incrementally; the database does not have to re-evaluate the entire view if its read set changes +- These views avoid the row materialization costs incurred by procedural view functions every time your code fetches/reads a row from the database + +For join-heavy views, this difference is often significant. + +In the example below, we have `players` and `moderators`. +The procedural version must execute as written: iterate filtered rows from `players`, then probe `moderators` row by row. +The query builder version expresses the same relationship declaratively, +which means the query engine is free to choose a better plan, +including reordering the join and starting from `moderators` if it thinks that will be more efficient. + + + + +```typescript +// Procedural: row-by-row join in module code. +export const high_scoring_moderators_procedural = spacetimedb.anonymousView( + { name: 'high_scoring_moderators_procedural', public: true }, + t.array(players.rowType), + (ctx) => { + return Array.from(ctx.db.players.score.filter({ gte: 100n })) + .filter(p => ctx.db.moderators.player_id.find(p.id) != null); + } +); + +// Query builder: equivalent logic pushed to the query engine. +// The engine can reorder this join if it decides the smaller side is a better starting point. +export const high_scoring_moderators_declarative = spacetimedb.anonymousView( + { name: 'high_scoring_moderators_declarative', public: true }, + t.array(players.rowType), + (ctx) => { + return ctx.from.players + .where(p => p.score.gte(100n)) + .leftSemijoin(ctx.from.moderators, (p, m) => p.id.eq(m.player_id)); + } +); +``` + + + + +```csharp +// Procedural: row-by-row join in module code. +[SpacetimeDB.View(Accessor = "HighScoringModeratorsProcedural", Public = true)] +public static List HighScoringModeratorsProcedural(AnonymousViewContext ctx) +{ + var results = new List(); + foreach (var player in ctx.Db.Player.Score.Filter((100, ulong.MaxValue))) + { + if (ctx.Db.Moderator.PlayerId.Find(player.Id) is Moderator) + { + results.Add(player); + } + } + return results; +} + +// Query builder: equivalent logic pushed to the query engine. +// The engine can reorder this join if it decides the smaller side is a better starting point. +[SpacetimeDB.View(Accessor = "HighScoringModerators", Public = true)] +public static IQuery HighScoringModerators(AnonymousViewContext ctx) +{ + return ctx.From.Player() + .Where(p => p.Score.Gte(100UL)) + .LeftSemijoin(ctx.From.Moderator(), (p, m) => p.Id.Eq(m.PlayerId)); +} +``` + + + + +```rust +// Procedural: row-by-row join in module code. +#[view(accessor = high_scoring_moderators_procedural, public)] +fn high_scoring_moderators_procedural(ctx: &AnonymousViewContext) -> Vec { + let mut out = Vec::new(); + for p in ctx.db.player().score().filter(100..) { + if ctx.db.moderator().player_id().find(p.id).is_some() { + out.push(p); + } + } + out +} + +// Query builder: equivalent logic pushed to the query engine. +// The engine can reorder this join if it decides the smaller side is a better starting point. +#[view(accessor = high_scoring_moderators_declarative, public)] +fn high_scoring_moderators_declarative(ctx: &AnonymousViewContext) -> impl Query { + ctx.from + .player() + .r#where(|p| p.score.gte(100u64)) + .left_semijoin(ctx.from.moderator(), |p, m| p.id.eq(m.player_id)) +} +``` + + + + +### API Reference + +#### Table Accessors + +The query builder is available only through `ViewContext` and `AnonymousViewContext`: +There is an accessor method/property for each table defined in your module. + + + + +```typescript +import { schema, table, t } from 'spacetimedb/server'; + +const players = table( + { name: 'players', public: true }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string(), + score: t.u64().index('btree'), + } +); + +const playerLevels = table( + { name: 'player_levels', public: true }, + { + player_id: t.u64().unique(), + level: t.u64().index('btree'), + } +); + +const spacetimedb = schema({ players, playerLevels }); +export default spacetimedb; + +export const all_players = spacetimedb.anonymousView( + { name: 'all_players', public: true }, + t.array(players.rowType), + (ctx) => ctx.from.players +); + +export const all_player_levels = spacetimedb.anonymousView( + { name: 'all_player_levels', public: true }, + t.array(playerLevels.rowType), + (ctx) => ctx.from.playerLevels +); +``` + + + + +```csharp +using SpacetimeDB; + +[SpacetimeDB.Table(Accessor = "Player", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + public string Name; + [SpacetimeDB.Index.BTree] + public ulong Score; +} + +[SpacetimeDB.Table(Accessor = "PlayerLevel", Public = true)] +public partial struct PlayerLevel +{ + [SpacetimeDB.Unique] + public ulong PlayerId; + [SpacetimeDB.Index.BTree] + public ulong Level; +} + +[SpacetimeDB.View(Accessor = "AllPlayers", Public = true)] +public static IQuery AllPlayers(AnonymousViewContext ctx) +{ + return ctx.From.Player(); +} + +[SpacetimeDB.View(Accessor = "AllPlayerLevels", Public = true)] +public static IQuery AllPlayerLevels(AnonymousViewContext ctx) +{ + return ctx.From.PlayerLevel(); +} +``` + + + + +```rust +use spacetimedb::{table, view, AnonymousViewContext, Query}; + +#[spacetimedb::table(name = player, public)] +pub struct Player { + #[primary_key] + #[auto_inc] + id: u64, + name: String, + #[index(btree)] + score: u64, +} + +#[spacetimedb::table(name = player_level, public)] +pub struct PlayerLevel { + #[unique] + player_id: u64, + #[index(btree)] + level: u64, +} + +#[view(accessor = all_players, public)] +fn all_players(ctx: &AnonymousViewContext) -> impl Query { + ctx.from.player() +} + +#[view(accessor = all_player_levels, public)] +fn all_player_levels(ctx: &AnonymousViewContext) -> impl Query { + ctx.from.player_level() +} +``` + + + + +#### `where` / `filter` + +Use `where` to apply predicates. Chaining multiple filters combines them with logical `AND`. + +`filter` is an alias for `where` in Rust and C#. + +##### Comparison operators + +| Operation | Rust | TypeScript | C# | +|----------|------|------------|----| +| Equal | `eq` | `eq` | `Eq` | +| Not equal | `ne` | `ne` | `Neq` | +| Less than | `lt` | `lt` | `Lt` | +| Less than or equal | `lte` | `lte` | `Lte` | +| Greater than | `gt` | `gt` | `Gt` | +| Greater than or equal | `gte` | `gte` | `Gte` | + + + + +```typescript +export const high_scorers = spacetimedb.anonymousView( + { name: 'high_scorers', public: true }, + t.array(players.rowType), + (ctx) => { + return ctx.from.players + .where(p => p.score.gte(1000n)) + .where(p => p.name.ne('BOT')); + } +); +``` + + + + +```csharp +[SpacetimeDB.View(Accessor = "HighScorers", Public = true)] +public static IQuery HighScorers(AnonymousViewContext ctx) +{ + return ctx.From.Player() + .Where(p => p.Score.Gte(1000UL)) + .Filter(p => p.Name.Neq("BOT")); +} +``` + + + + +```rust +#[view(accessor = high_scorers, public)] +fn high_scorers(ctx: &AnonymousViewContext) -> impl Query { + ctx.from + .player() + .r#where(|p| p.score.gte(1000u64)) + .filter(|p| p.name.ne("BOT")) +} +``` + + + + +##### Boolean combinators + +Combine conditions with `and`, `or`, and `not`: + + + + +```typescript +ctx.from.players.where(p => p.score.gte(1000n).and(p.score.lt(5000n))); +ctx.from.players.where(p => p.name.eq('ADMIN').or(p.name.eq('BOT'))); +ctx.from.players.where(p => p.name.eq('BOT').not()); +``` + + + + +```csharp +ctx.From.Player().Where(p => p.Score.Gte(1000UL).And(p.Score.Lt(5000UL))); +ctx.From.Player().Where(p => p.Name.Eq("ADMIN").Or(p.Name.Eq("BOT"))); +ctx.From.Player().Where(p => p.Name.Eq("BOT").Not()); +``` + + + + +```rust +ctx.from.player().r#where(|p| p.score.gte(1000u64).and(p.score.lt(5000u64))); +ctx.from.player().r#where(|p| p.name.eq("ADMIN").or(p.name.eq("BOT"))); +ctx.from.player().r#where(|p| p.name.eq("BOT").not()); +``` + + + + +Comparisons are strongly typed, meaning invalid comparisons are rejected by the type system. +An example would be comparing an `Identity` column to an integer literal. + +#### Semijoins + +Semijoins keep rows from one side when a matching row exists on the other side. They are used for filtering rows in one table based on rows in the other table. + +- A left semijoin returns rows from the left side that have at least one match on the right side. +- A right semijoin returns rows from the right side that have at least one match on the left side. +- Filters chained before a semijoin apply to the pre-join source side. +- Filters chained after a semijoin apply to whichever side the semijoin returns. +- Like `where/filter`, join predicates are strongly typed +- Additionally join predicates may only use indexed columns with multi-column indexes not supported at the moment + + + + +```typescript +export const players_with_levels = spacetimedb.anonymousView( + { name: 'players_with_levels', public: true }, + t.array(players.rowType), + (ctx) => { + return ctx.from.players + .leftSemijoin(ctx.from.playerLevels, (p, pl) => p.id.eq(pl.player_id)); + } +); + +export const levels_for_high_scorers = spacetimedb.anonymousView( + { name: 'levels_for_high_scorers', public: true }, + t.array(playerLevels.rowType), + (ctx) => { + return ctx.from.players + .where(p => p.score.gte(1000n)) + .rightSemijoin(ctx.from.playerLevels, (p, pl) => p.id.eq(pl.player_id)) + .where(pl => pl.level.gte(10n)); + } +); +``` + + + + +```csharp +[SpacetimeDB.View(Accessor = "PlayersWithLevels", Public = true)] +public static IQuery PlayersWithLevels(AnonymousViewContext ctx) +{ + return ctx.From.Player() + .LeftSemijoin(ctx.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId)); +} + +[SpacetimeDB.View(Accessor = "LevelsForHighScorers", Public = true)] +public static IQuery LevelsForHighScorers(AnonymousViewContext ctx) +{ + return ctx.From.Player() + .Where(p => p.Score.Gte(1000UL)) + .RightSemijoin(ctx.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId)) + .Where(pl => pl.Level.Gte(10UL)); +} +``` + + + + +```rust +#[view(accessor = players_with_levels, public)] +fn players_with_levels(ctx: &AnonymousViewContext) -> impl Query { + ctx.from + .player() + .left_semijoin(ctx.from.player_level(), |p, pl| p.id.eq(pl.player_id)) +} + +#[view(accessor = levels_for_high_scorers, public)] +fn levels_for_high_scorers(ctx: &AnonymousViewContext) -> impl Query { + ctx.from + .player() + .r#where(|p| p.score.gte(1000u64)) + .right_semijoin(ctx.from.player_level(), |p, pl| p.id.eq(pl.player_id)) + .r#where(|pl| pl.level.gte(10u64)) +} +``` + + + ## Next Steps -- Review [Subscriptions](/subscriptions) for real-time client data access +- Review [Subscriptions](/clients/subscriptions) for real-time client data access diff --git a/docs/docs/00200-core-concepts/00300-tables.md b/docs/docs/00200-core-concepts/00300-tables.md index a9e1e4adf..5b871dbae 100644 --- a/docs/docs/00200-core-concepts/00300-tables.md +++ b/docs/docs/00200-core-concepts/00300-tables.md @@ -304,7 +304,7 @@ These conventions align with each language's standard style guides and make your Tables can be **private** (default) or **public**: - **Private tables**: Visible only to [reducers](/functions/reducers) and the database owner. Clients cannot access them. -- **Public tables**: Exposed for client read access through [subscriptions](/subscriptions). Writes still occur only through reducers. +- **Public tables**: Exposed for client read access through [subscriptions](/clients/subscriptions). Writes still occur only through reducers. diff --git a/docs/docs/00200-core-concepts/00300-tables/00400-access-permissions.md b/docs/docs/00200-core-concepts/00300-tables/00400-access-permissions.md index 2662b886f..a94eff324 100644 --- a/docs/docs/00200-core-concepts/00300-tables/00400-access-permissions.md +++ b/docs/docs/00200-core-concepts/00300-tables/00400-access-permissions.md @@ -957,4 +957,4 @@ SPACETIMEDB_VIEW(std::vector, my_colleagues, Public, ViewContext ctx) ## Client Access - Read-Only Access -Clients connect to databases and can access public tables and views through subscriptions and queries. They cannot access private tables directly. See the [Subscriptions documentation](/subscriptions) for details on client-side table access. +Clients connect to databases and can access public tables and views through subscriptions and queries. They cannot access private tables directly. See the [Subscriptions documentation](/clients/subscriptions) for details on client-side table access. diff --git a/docs/docs/00200-core-concepts/00300-tables/00600-performance.md b/docs/docs/00200-core-concepts/00300-tables/00600-performance.md index e326b20ee..6305ef774 100644 --- a/docs/docs/00200-core-concepts/00300-tables/00600-performance.md +++ b/docs/docs/00200-core-concepts/00300-tables/00600-performance.md @@ -638,5 +638,5 @@ Be mindful of unbounded table growth: ## Next Steps - Learn about [Indexes](/tables/indexes) to optimize queries -- Explore [Subscriptions](/subscriptions) for efficient client data sync +- Explore [Subscriptions](/clients/subscriptions) for efficient client data sync - Review [Reducers](/functions/reducers) for efficient data modification patterns diff --git a/docs/docs/00200-core-concepts/00400-subscriptions.md b/docs/docs/00200-core-concepts/00400-subscriptions.md index 3735f1470..835bd475a 100644 --- a/docs/docs/00200-core-concepts/00400-subscriptions.md +++ b/docs/docs/00200-core-concepts/00400-subscriptions.md @@ -1,6 +1,6 @@ --- -title: Subscription Reference -slug: /subscriptions +title: Subscriptions +slug: /clients/subscriptions --- import Tabs from '@theme/Tabs'; @@ -74,7 +74,9 @@ var conn = DbConnection.Builder() Console.WriteLine($"User: {user.Name}"); } }) - .Subscribe(new[] { "SELECT * FROM user", "SELECT * FROM message" }); + .AddQuery(q => q.From.User()) + .AddQuery(q => q.From.Message()) + .Subscribe(); }) .Build(); @@ -115,7 +117,9 @@ let conn = DbConnection::builder() println!("User: {}", user.name); } }) - .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); + .add_query(|q| q.from.user()) + .add_query(|q| q.from.message()) + .subscribe(); }) .build(); @@ -138,465 +142,68 @@ conn.db().user().on_update(|ctx, old_user, new_user| { -:::tip TypeScript Query Builders -The TypeScript SDK supports type-safe **query builders** as an alternative to raw SQL strings. Query builders provide auto-completion and compile-time type checking. The examples on this page use query builders for TypeScript — see the [TypeScript SDK Reference](/sdks/typescript#query-builder-api) for full details. Raw SQL strings are still supported in all SDKs. +:::tip Typed Query Builders +Type-safe query builders are available in TypeScript, C#, and Rust and are the recommended default. They provide auto-completion and compile-time type checking. For complete API details, see [TypeScript](/clients/typescript#query-builder-api), [C#](/clients/c-sharp#query-builder-api), and [Rust](/clients/rust#query-builder-api) references. ::: ## How Subscriptions Work -1. **Subscribe**: Register SQL queries describing the data you need +1. **Subscribe**: Subscribe with queries to the data you need 2. **Receive initial data**: SpacetimeDB sends all matching rows immediately 3. **Receive updates**: When subscribed rows change, you get real-time updates 4. **React to changes**: Use row callbacks (`onInsert`, `onDelete`, `onUpdate`) to handle changes The client maintains a local cache of subscribed data. Reading from the cache is instant since it's local memory. -For more information on subscription SQL syntax see the [SQL docs](/reference/sql#subscriptions). +For advanced raw SQL subscription syntax, see the [SQL docs](/reference/sql#subscriptions). -## API Reference +## Common API Concepts -This section describes the two main interfaces: `SubscriptionBuilder` and `SubscriptionHandle`. +This page focuses on subscription behavior and usage patterns that apply across SDKs. For exact method signatures and SDK-specific overloads, use the language references. -## SubscriptionBuilder +### Builder and Lifecycle Callbacks - - +All SDKs expose a builder API for creating subscriptions: -```typescript -interface SubscriptionBuilder { - // Register a callback to run when the subscription is applied. - onApplied(callback: (ctx: SubscriptionEventContext) => void): SubscriptionBuilder; +- Register an applied callback: runs once initial matching rows are present in the local cache. +- Register an error callback: runs if subscription registration fails or a subscription later terminates with an error. +- Subscribe with one or more queries. - // Register a callback to run when the subscription fails. - // This callback may run when attempting to apply the subscription, - // or later during the subscription's lifetime if the module's interface changes. - onError(callback: (ctx: ErrorContext, error: Error) => void): SubscriptionBuilder; +### Query Forms - // Subscribe using query builders (recommended) or raw SQL strings. - // Returns immediately; callbacks are invoked when data arrives from the server. - subscribe(queries: string | string[]): SubscriptionHandle; - subscribe(queries: TableRef | TableRef[]): SubscriptionHandle; +All SDKs support subscriptions. TypeScript, C#, and Rust support query builders (recommended), while Unreal uses query strings: - // Subscribe to all rows from all tables. - // Intended for applications where memory and bandwidth are not concerns. - subscribeToAllTables(): void; -} -``` +| SDK | Typed Query Builder Support | Entry Point | +| --- | --- | --- | +| TypeScript | Yes | `tables..where(...)` passed to `subscribe(...)` | +| C# | Yes | `SubscriptionBuilder.AddQuery(...).Subscribe()` | +| Rust | Yes | `subscription_builder().add_query(...).subscribe()` | +| Unreal | No | Query strings passed to `Subscribe(...)` | - - +### Subscription Handles -```cs -public sealed class SubscriptionBuilder -{ - /// - /// Register a callback to run when the subscription is applied. - /// - public SubscriptionBuilder OnApplied( - Action callback - ); +Subscribing returns a handle that manages an individual subscription lifecycle. - /// - /// Register a callback to run when the subscription fails. - /// - /// Note that this callback may run either when attempting to apply the subscription, - /// in which case Self::on_applied will never run, - /// or later during the subscription's lifetime if the module's interface changes, - /// in which case Self::on_applied may have already run. - /// - public SubscriptionBuilder OnError( - Action callback - ); +- `isActive` / `IsActive` / `is_active` indicates that matching rows are currently active in the cache. +- `isEnded` / `IsEnded` / `is_ended` indicates a subscription has ended, either from unsubscribe or error. +- Unsubscribe is asynchronous: rows are removed after the unsubscribe operation is applied. +- `subscribeToAllTables` / `SubscribeToAllTables` / `subscribe_to_all_tables` is a convenience entry point intended for simple clients and is not individually cancelable. - /// - /// Subscribe to the following SQL queries. - /// - /// This method returns immediately, with the data not yet added to the DbConnection. - /// The provided callbacks will be invoked once the data is returned from the remote server. - /// Data from all the provided queries will be returned at the same time. - /// - /// See the SpacetimeDB SQL docs for more information on SQL syntax: - /// pathname:///docs/sql - /// - public SubscriptionHandle Subscribe( - string[] querySqls - ); +### API References - /// - /// Subscribe to all rows from all tables. - /// - /// This method is intended as a convenience - /// for applications where client-side memory use and network bandwidth are not concerns. - /// Applications where these resources are a constraint - /// should register more precise queries via Self.Subscribe - /// in order to replicate only the subset of data which the client needs to function. - /// - public void SubscribeToAllTables(); - - /// - /// Add a typed query to this subscription. - /// - /// This is the entry point for building subscriptions without writing SQL by hand. - /// Once a typed query is added, only typed queries may follow (SQL and typed queries cannot be mixed). - /// - public TypedSubscriptionBuilder AddQuery( - Func> build - ); -} - -public sealed class TypedSubscriptionBuilder -{ - /// - /// Add a typed query to this subscription. - /// - public TypedSubscriptionBuilder AddQuery( - Func> build - ); - - /// - /// Subscribe to all typed queries that have been added to this subscription. - /// - public SubscriptionHandle Subscribe(); -} -``` - - - - -```rust -pub struct SubscriptionBuilder { /* private fields */ } - -impl SubscriptionBuilder { - /// Register a callback that runs when the subscription has been applied. - /// This callback receives a context containing the current state of the subscription. - pub fn on_applied(mut self, callback: impl FnOnce(&M::SubscriptionEventContext) + Send + 'static); - - /// Register a callback to run when the subscription fails. - /// - /// Note that this callback may run either when attempting to apply the subscription, - /// in which case [`Self::on_applied`] will never run, - /// or later during the subscription's lifetime if the module's interface changes, - /// in which case [`Self::on_applied`] may have already run. - pub fn on_error(mut self, callback: impl FnOnce(&M::ErrorContext, crate::Error) + Send + 'static); - - /// Subscribe to a subset of the database via a set of SQL queries. - /// Returns a handle which you can use to monitor or drop the subscription later. - pub fn subscribe(self, query_sql: Queries) -> M::SubscriptionHandle; - - /// Subscribe to all rows from all tables. - /// - /// This method is intended as a convenience - /// for applications where client-side memory use and network bandwidth are not concerns. - /// Applications where these resources are a constraint - /// should register more precise queries via [`Self::subscribe`] - /// in order to replicate only the subset of data which the client needs to function. - pub fn subscribe_to_all_tables(self); - - /// Build a query and invoke `subscribe` in order to subscribe to its results. - pub fn add_query(self, build: impl Fn(M::QueryBuilder) -> impl Query) -> TypedSubscriptionBuilder; -} - -impl TypedSubscriptionBuilder { - /// Build a query and invoke `subscribe` in order to subscribe to its results. - pub fn add_query(mut self, build: impl Fn(M::QueryBuilder) -> impl Query) -> Self; - - /// Subscribe to the queries that have been built with `add_query`. - pub fn subscribe(self) -> M::SubscriptionHandle; -} - -/// Types which specify a list of query strings. -pub trait IntoQueries { - fn into_queries(self) -> Box<[Box]>; -} -``` - - - - -A `SubscriptionBuilder` provides an interface for registering subscription queries with a database. -It allows you to register callbacks that run when the subscription is successfully applied or when an error occurs. -Once applied, a client will start receiving row updates to its client cache. -A client can react to these updates by registering row callbacks for the appropriate table. - -### Example Usage - - - - -```typescript -// Establish a database connection -import { DbConnection, tables } from './module_bindings'; - -const conn = DbConnection.builder() - .withUri('https://maincloud.spacetimedb.com') - .withDatabaseName('my_module') - .build(); - -// Register a subscription with the database using query builders -const userSubscription = conn - .subscriptionBuilder() - .onApplied((ctx) => { /* handle applied state */ }) - .onError((ctx, error) => { /* handle error */ }) - .subscribe([tables.user, tables.message]); -``` - - - - -```cs -// Establish a database connection -var conn = ConnectToDB(); - -// Register a subscription with the database -var userSubscription = conn - .SubscriptionBuilder() - .OnApplied((ctx) => { /* handle applied state */ }) - .OnError((errorCtx, error) => { /* handle error */ }) - .Subscribe(new string[] { "SELECT * FROM user", "SELECT * FROM message" }); -``` - - - - -```rust -// Establish a database connection -let conn: DbConnection = connect_to_db(); - -// Register a subscription with the database -let subscription_handle = conn - .subscription_builder() - .on_applied(|ctx| { /* handle applied state */ }) - .on_error(|error_ctx, error| { /* handle error */ }) - .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); -``` - - - - -## SubscriptionHandle - - - - -```typescript -interface SubscriptionHandle { - // Whether the subscription has ended (unsubscribed or terminated due to error). - isEnded(): boolean; - - // Whether the subscription is currently active. - isActive(): boolean; - - // Unsubscribe from the query controlled by this handle. - // Throws if called more than once. - unsubscribe(): void; - - // Unsubscribe and call onEnded when rows are removed from the client cache. - unsubscribeThen(onEnded?: (ctx: SubscriptionEventContext) => void): void; -} -``` - - - - -```cs - public class SubscriptionHandle : ISubscriptionHandle - where SubscriptionEventContext : ISubscriptionEventContext - where ErrorContext : IErrorContext - { - /// - /// Whether the subscription has ended. - /// - public bool IsEnded; - - /// - /// Whether the subscription is active. - /// - public bool IsActive; - - /// - /// Unsubscribe from the query controlled by this subscription handle. - /// - /// Calling this more than once will result in an exception. - /// - public void Unsubscribe(); - - /// - /// Unsubscribe from the query controlled by this subscription handle, - /// and call onEnded when its rows are removed from the client cache. - /// - public void UnsubscribeThen(Action? onEnded); - } -``` - - - - -```rust -pub trait SubscriptionHandle: InModule + Clone + Send + 'static -where - Self::Module: SpacetimeModule, -{ - /// Returns `true` if the subscription has been ended. - /// That is, if it has been unsubscribed or terminated due to an error. - fn is_ended(&self) -> bool; - - /// Returns `true` if the subscription is currently active. - fn is_active(&self) -> bool; - - /// Unsubscribe from the query controlled by this `SubscriptionHandle`, - /// then run `on_end` when its rows are removed from the client cache. - /// Returns an error if the subscription is already ended, - /// or if unsubscribe has already been called. - fn unsubscribe_then(self, on_end: OnEndedCallback) -> crate::Result<()>; - - /// Unsubscribe from the query controlled by this `SubscriptionHandle`. - /// Returns an error if the subscription is already ended, - /// or if unsubscribe has already been called. - fn unsubscribe(self) -> crate::Result<()>; -} -``` - - - - -When you register a subscription, you receive a `SubscriptionHandle`. -A `SubscriptionHandle` manages the lifecycle of each subscription you register. -In particular, it provides methods to check the status of the subscription and to unsubscribe if necessary. -Because each subscription has its own independently managed lifetime, -clients can dynamically subscribe to different subsets of the database as their application requires. - -### Example Usage - - - - -Consider a game client that displays shop items and discounts based on a player's level. -You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: - -```typescript -import { DbConnection, tables } from './module_bindings'; - -const conn = DbConnection.builder() - .withUri('https://maincloud.spacetimedb.com') - .withDatabaseName('my_module') - .build(); - -const shopItemsSubscription = conn - .subscriptionBuilder() - .onApplied((ctx) => { /* handle applied state */ }) - .onError((ctx, error) => { /* handle error */ }) - .subscribe([ - tables.shopItems.where(r => r.requiredLevel.lte(5)), - tables.shopDiscounts.where(r => r.requiredLevel.lte(5)), - ]); -``` - -Later, when the player reaches level 6 and new items become available, -you can subscribe to the new queries and unsubscribe from the old ones: - -```typescript -const newShopItemsSubscription = conn - .subscriptionBuilder() - .onApplied((ctx) => { /* handle applied state */ }) - .onError((ctx, error) => { /* handle error */ }) - .subscribe([ - tables.shopItems.where(r => r.requiredLevel.lte(6)), - tables.shopDiscounts.where(r => r.requiredLevel.lte(6)), - ]); - -if (shopItemsSubscription.isActive()) { - shopItemsSubscription.unsubscribe(); -} -``` - -All other subscriptions continue to remain in effect. - - - - -Consider a game client that displays shop items and discounts based on a player's level. -You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: - -```cs -var conn = ConnectToDB(); - -var shopItemsSubscription = conn - .SubscriptionBuilder() - .OnApplied((ctx) => { /* handle applied state */ }) - .OnError((errorCtx, error) => { /* handle error */ }) - .Subscribe(new string[] { - "SELECT * FROM shop_items WHERE required_level <= 5", - "SELECT * FROM shop_discounts WHERE required_level <= 5", - }); -``` - -Later, when the player reaches level 6 and new items become available, -you can subscribe to the new queries and unsubscribe from the old ones: - -```cs -var newShopItemsSubscription = conn - .SubscriptionBuilder() - .OnApplied((ctx) => { /* handle applied state */ }) - .OnError((errorCtx, error) => { /* handle error */ }) - .Subscribe(new string[] { - "SELECT * FROM shop_items WHERE required_level <= 6", - "SELECT * FROM shop_discounts WHERE required_level <= 6", - }); - -if (shopItemsSubscription.IsActive) -{ - shopItemsSubscription.Unsubscribe(); -} -``` - -All other subscriptions continue to remain in effect. - - -Consider a game client that displays shop items and discounts based on a player's level. -You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: - -```rust -let conn: DbConnection = connect_to_db(); - -let shop_items_subscription = conn - .subscription_builder() - .on_applied(|ctx| { /* handle applied state */ }) - .on_error(|error_ctx, error| { /* handle error */ }) - .subscribe([ - "SELECT * FROM shop_items WHERE required_level <= 5", - "SELECT * FROM shop_discounts WHERE required_level <= 5", - ]); -``` - -Later, when the player reaches level 6 and new items become available, -you can subscribe to the new queries and unsubscribe from the old ones: - -```rust -let new_shop_items_subscription = conn - .subscription_builder() - .on_applied(|ctx| { /* handle applied state */ }) - .on_error(|error_ctx, error| { /* handle error */ }) - .subscribe([ - "SELECT * FROM shop_items WHERE required_level <= 6", - "SELECT * FROM shop_discounts WHERE required_level <= 6", - ]); - -if shop_items_subscription.is_active() { - shop_items_subscription - .unsubscribe() - .expect("Unsubscribing from shop_items failed"); -} -``` - -All other subscriptions continue to remain in effect. - - +- [TypeScript subscription API](/clients/typescript#subscribe-to-queries) +- [TypeScript query builder API](/clients/typescript#query-builder-api) +- [C# subscription API](/clients/c-sharp#subscribe-to-queries) +- [C# query builder API](/clients/c-sharp#query-builder-api) +- [Rust subscription API](/clients/rust#subscribe-to-queries) +- [Rust query builder API](/clients/rust#query-builder-api) +- [Unreal subscription API](/clients/unreal#subscriptions) ## Best Practices for Optimizing Server Compute and Reducing Serialization Overhead -### 1. Writing Efficient SQL Queries +### 1. Writing Efficient Subscription Queries -For writing efficient SQL queries, see our [SQL Best Practices Guide](/reference/sql#best-practices-for-performance-and-scalability). +Use the typed query builder to express precise filters and keep subscriptions small. If you use raw SQL subscriptions, see [SQL Best Practices](/reference/sql#best-practices-for-performance-and-scalability). ### 2. Group Subscriptions with the Same Lifetime Together @@ -651,19 +258,15 @@ var conn = ConnectToDB(); // Never need to unsubscribe from global subscriptions var globalSubscriptions = conn .SubscriptionBuilder() - .Subscribe(new string[] { - // Global messages the client should always display - "SELECT * FROM announcements", - // A description of rewards for in-game achievements - "SELECT * FROM badges", - }); + .AddQuery(q => q.From.Announcements()) + .AddQuery(q => q.From.Badges()) + .Subscribe(); // May unsubscribe to shop_items as player advances var shopSubscription = conn .SubscriptionBuilder() - .Subscribe(new string[] { - "SELECT * FROM shop_items WHERE required_level <= 5" - }); + .AddQuery(q => q.From.ShopItems().Where(r => r.RequiredLevel.Lte(5U))) + .Subscribe(); ``` @@ -675,19 +278,15 @@ let conn: DbConnection = connect_to_db(); // Never need to unsubscribe from global subscriptions let global_subscriptions = conn .subscription_builder() - .subscribe([ - // Global messages the client should always display - "SELECT * FROM announcements", - // A description of rewards for in-game achievements - "SELECT * FROM badges", - ]); + .add_query(|q| q.from.announcements()) + .add_query(|q| q.from.badges()) + .subscribe(); // May unsubscribe to shop_items as player advances let shop_subscription = conn .subscription_builder() - .subscribe([ - "SELECT * FROM shop_items WHERE required_level <= 5", - ]); + .add_query(|q| q.from.shop_items().r#where(|r| r.required_level.lte(5u32))) + .subscribe(); ``` @@ -749,20 +348,16 @@ var conn = ConnectToDB(); // Initial subscription: player at level 5. var shopSubscription = conn .SubscriptionBuilder() - .Subscribe(new string[] { - // For displaying the price of shop items in the player's currency of choice - "SELECT * FROM exchange_rates", - "SELECT * FROM shop_items WHERE required_level <= 5" - }); + .AddQuery(q => q.From.ExchangeRates()) + .AddQuery(q => q.From.ShopItems().Where(r => r.RequiredLevel.Lte(5U))) + .Subscribe(); // New subscription: player now at level 6, which overlaps with the previous query. var newShopSubscription = conn .SubscriptionBuilder() - .Subscribe(new string[] { - // For displaying the price of shop items in the player's currency of choice - "SELECT * FROM exchange_rates", - "SELECT * FROM shop_items WHERE required_level <= 6" - }); + .AddQuery(q => q.From.ExchangeRates()) + .AddQuery(q => q.From.ShopItems().Where(r => r.RequiredLevel.Lte(6U))) + .Subscribe(); // Unsubscribe from the old subscription once the new one is in place. if (shopSubscription.IsActive) @@ -780,20 +375,16 @@ let conn: DbConnection = connect_to_db(); // Initial subscription: player at level 5. let shop_subscription = conn .subscription_builder() - .subscribe([ - // For displaying the price of shop items in the player's currency of choice - "SELECT * FROM exchange_rates", - "SELECT * FROM shop_items WHERE required_level <= 5", - ]); + .add_query(|q| q.from.exchange_rates()) + .add_query(|q| q.from.shop_items().r#where(|r| r.required_level.lte(5u32))) + .subscribe(); // New subscription: player now at level 6, which overlaps with the previous query. let new_shop_subscription = conn .subscription_builder() - .subscribe([ - // For displaying the price of shop items in the player's currency of choice - "SELECT * FROM exchange_rates", - "SELECT * FROM shop_items WHERE required_level <= 6", - ]); + .add_query(|q| q.from.exchange_rates()) + .add_query(|q| q.from.shop_items().r#where(|r| r.required_level.lte(6u32))) + .subscribe(); // Unsubscribe from the old subscription once the new one is active. if shop_subscription.is_active() { @@ -810,11 +401,11 @@ This refers to distinct queries that return intersecting data sets, which can result in the server processing and serializing the same row multiple times. While SpacetimeDB can manage this redundancy, it may lead to unnecessary inefficiencies. -Consider the following two queries: +Consider the following two query builder subscriptions: -```sql -SELECT * FROM User -SELECT * FROM User WHERE id = 5 +```typescript +tables.user +tables.user.where(r => r.id.eq(5)) ``` If `User.id` is a unique or primary key column, @@ -822,11 +413,11 @@ the cost of subscribing to both queries is minimal. This is because the server will use an index when processing the 2nd query, and it will only serialize a single row for the 2nd query. -In contrast, consider these two queries: +In contrast, consider these two query builder subscriptions: -```sql -SELECT * FROM User -SELECT * FROM User WHERE id != 5 +```typescript +tables.user +tables.user.where(r => r.id.ne(5)) ``` The server must now process each row of the `User` table twice, diff --git a/docs/docs/00200-core-concepts/00400-subscriptions/00200-subscription-semantics.md b/docs/docs/00200-core-concepts/00400-subscriptions/00200-subscription-semantics.md index 8677be4dc..a5c378cf0 100644 --- a/docs/docs/00200-core-concepts/00400-subscriptions/00200-subscription-semantics.md +++ b/docs/docs/00200-core-concepts/00400-subscriptions/00200-subscription-semantics.md @@ -1,6 +1,6 @@ --- title: Subscription Semantics -slug: /subscriptions/semantics +slug: /clients/subscriptions/semantics --- diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00400-sdk-api.md b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00400-sdk-api.md deleted file mode 100644 index d9f9f299d..000000000 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00400-sdk-api.md +++ /dev/null @@ -1,412 +0,0 @@ ---- -title: SDK API Overview -slug: /sdks/api ---- - -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - - -The SpacetimeDB client SDKs provide a comprehensive API for interacting with your [database](/databases). After [generating client bindings](/sdks/codegen) and [establishing a connection](/sdks/connection), you can query data, invoke server functions, and observe real-time changes. - -This page describes the core concepts and patterns that apply across all client SDKs. For language-specific details and complete API documentation, see the reference pages for [Rust](/sdks/rust), [C#](/sdks/c-sharp), [TypeScript](/sdks/typescript), or [Unreal Engine](/sdks/unreal). - -## Prerequisites - -Before using the SDK API, you must: - -1. [Generate client bindings](/sdks/codegen) using `spacetime generate` -2. [Create a connection](/sdks/connection) to your database - -## Subscriptions - -Subscriptions replicate a subset of the database to your client, maintaining a local cache that automatically updates as the server state changes. Clients should subscribe to the data they need, then query the local cache. - -### Creating Subscriptions - -Subscribe to tables or queries using raw SQL: - - - - -```typescript -import { tables } from './module_bindings'; - -// Subscribe with query builders (recommended) -conn - .subscriptionBuilder() - .onApplied(ctx => { - console.log(`Subscription ready with ${ctx.db.User.count()} users`); - }) - .onError((ctx, error) => { - console.error(`Subscription failed: ${error}`); - }) - .subscribe([tables.user]); - -// Raw SQL is also supported: -// .subscribe(['SELECT * FROM user']); -``` - - - - -```csharp -// Subscribe with callbacks -conn.SubscriptionBuilder() - .OnApplied(ctx => - { - Console.WriteLine($"Subscription ready with {ctx.Db.User.Count()} users"); - }) - .OnError((ctx, error) => - { - Console.WriteLine($"Subscription failed: {error}"); - }) - .Subscribe("SELECT * FROM user"); -``` - - - - -```rust -// Subscribe with callbacks -conn.subscription_builder() - .on_applied(|ctx| { - println!("Subscription ready with {} users", ctx.db().user().count()); - }) - .on_error(|ctx, error| { - eprintln!("Subscription failed: {}", error); - }) - .subscribe(["SELECT * FROM user"]); -``` - - - - -```cpp -// Create and bind delegates -FOnSubscriptionApplied AppliedDelegate; -AppliedDelegate.BindDynamic(this, &AMyActor::OnSubscriptionApplied); - -FOnSubscriptionError ErrorDelegate; -ErrorDelegate.BindDynamic(this, &AMyActor::OnSubscriptionError); - -// Subscribe with callbacks -TArray Queries = { TEXT("SELECT * FROM user") }; -Conn->SubscriptionBuilder() - ->OnApplied(AppliedDelegate) - ->OnError(ErrorDelegate) - ->Subscribe(Queries); - -// Callback functions (must be UFUNCTION) -UFUNCTION() -void OnSubscriptionApplied(const FSubscriptionEventContext& Ctx) -{ - int32 UserCount = Ctx.Db->User->Count(); - UE_LOG(LogTemp, Log, TEXT("Subscription ready with %d users"), UserCount); -} - -UFUNCTION() -void OnSubscriptionError(const FErrorContext& Ctx) -{ - UE_LOG(LogTemp, Error, TEXT("Subscription failed: %s"), *Ctx.Error); -} -``` - - - - -Or use the query builder: - - - - -```typescript -import { queries } from './module_bindings'; - -// Subscribe with callbacks -conn - .subscriptionBuilder() - .onApplied(ctx => { - console.log(`Subscription ready with ${ctx.db.User.count()} users`); - }) - .onError((ctx, error) => { - console.error(`Subscription failed: ${error}`); - }) - .subscribe([queries.user]); -``` - - - - -```csharp -// Subscribe with callbacks -conn.SubscriptionBuilder() - .OnApplied(ctx => - { - Console.WriteLine($"Subscription ready with {ctx.Db.User.Count()} users"); - }) - .OnError((ctx, error) => - { - Console.WriteLine($"Subscription failed: {error}"); - }) - .AddQuery(ctx => ctx.From.User()) - .Subscribe(); -``` - - - - -```rust -// Subscribe with callbacks -conn.subscription_builder() - .on_applied(|ctx| { - println!("Subscription ready with {} users", ctx.db().user().count()); - }) - .on_error(|ctx, error| { - eprintln!("Subscription failed: {}", error); - }) - .add_query(|ctx| ctx.from.user()) - .subscribe(); -``` - - - - -See the [Subscriptions documentation](/subscriptions) for detailed information on subscription queries and semantics. Subscribe to [tables](/tables) for row data, or to [views](/functions/views) for computed query results. - -### Querying the Local Cache - -Once subscribed, query the local cache without network round-trips: - - - - -```typescript -// Iterate all cached rows -for (const user of conn.db.user.iter()) { - console.log(`${user.id}: ${user.name}`); -} - -// Count cached rows -const userCount = conn.db.user.count(); - -// Find by unique column (if indexed) -const user = conn.db.user.name.find('Alice'); -if (user) { - console.log(`Found: ${user.email}`); -} - -// Filter cached rows -const adminUsers = [...conn.db.user.iter()].filter(u => u.isAdmin); -``` - - - - -```csharp -// Iterate all cached rows -foreach (var user in conn.Db.User.Iter()) -{ - Console.WriteLine($"{user.Id}: {user.Name}"); -} - -// Count cached rows -var userCount = conn.Db.User.Count; - -// Find by unique column (if indexed) -var user = conn.Db.User.Name.Find("Alice"); -if (user != null) -{ - Console.WriteLine($"Found: {user.Email}"); -} - -// Filter cached rows (using LINQ) -var adminUsers = conn.Db.User.Iter() - .Where(u => u.IsAdmin) - .ToList(); -``` - - - - -```rust -// Iterate all cached rows -for user in conn.db().user().iter() { - println!("{}: {}", user.id, user.name); -} - -// Count cached rows -let user_count = conn.db().user().count(); - -// Find by unique column (if indexed) -if let Some(user) = conn.db().user().name().find("Alice") { - println!("Found: {}", user.email); -} - -// Filter cached rows -let admin_users: Vec<_> = conn.db().user() - .iter() - .filter(|u| u.is_admin) - .collect(); -``` - - - - -```cpp -// Iterate all cached rows -TArray Users = Conn->Db->User->Iter(); -for (const FUserType& User : Users) -{ - UE_LOG(LogTemp, Log, TEXT("%lld: %s"), User.Id, *User.Name); -} - -// Count cached rows -int32 UserCount = Conn->Db->User->Count(); - -// Find by unique column (if indexed) -FUserType User = Conn->Db->User->Name->Find(TEXT("Alice")); -if (!User.Name.IsEmpty()) -{ - UE_LOG(LogTemp, Log, TEXT("Found: %s"), *User.Email); -} - -// Filter cached rows -TArray AllUsers = Conn->Db->User->Iter(); -TArray AdminUsers; -for (const FUserType& User : AllUsers) -{ - if (User.IsAdmin) - { - AdminUsers.Add(User); - } -} -``` - - - - -### Row Update Callbacks - -Register callbacks to observe insertions, updates, and deletions in the local cache: - - - - -```typescript -// Called when a row is inserted -conn.db.User.onInsert((ctx, user) => { - console.log(`User inserted: ${user.name}`); -}); - -// Called when a row is updated -conn.db.User.onUpdate((ctx, oldUser, newUser) => { - console.log(`User ${newUser.id} updated: ${oldUser.name} -> ${newUser.name}`); -}); - -// Called when a row is deleted -conn.db.User.onDelete((ctx, user) => { - console.log(`User deleted: ${user.name}`); -}); -``` - - - - -```csharp -// Called when a row is inserted -conn.Db.User.OnInsert += (ctx, user) => -{ - Console.WriteLine($"User inserted: {user.Name}"); -}; - -// Called when a row is updated -conn.Db.User.OnUpdate += (ctx, oldUser, newUser) => -{ - Console.WriteLine($"User {newUser.Id} updated: {oldUser.Name} -> {newUser.Name}"); -}; - -// Called when a row is deleted -conn.Db.User.OnDelete += (ctx, user) => -{ - Console.WriteLine($"User deleted: {user.Name}"); -}; -``` - - - - -```rust -// Called when a row is inserted -conn.db().user().on_insert(|ctx, user| { - println!("User inserted: {}", user.name); -}); - -// Called when a row is updated -conn.db().user().on_update(|ctx, old_user, new_user| { - println!("User {} updated: {} -> {}", - new_user.id, old_user.name, new_user.name); -}); - -// Called when a row is deleted -conn.db().user().on_delete(|ctx, user| { - println!("User deleted: {}", user.name); -}); -``` - - - - -```cpp -// Called when a row is inserted -Conn->Db->User->OnInsert.AddDynamic(this, &AMyActor::OnUserInsert); - -// Called when a row is updated -Conn->Db->User->OnUpdate.AddDynamic(this, &AMyActor::OnUserUpdate); - -// Called when a row is deleted -Conn->Db->User->OnDelete.AddDynamic(this, &AMyActor::OnUserDelete); - -// Callback functions (must be UFUNCTION) -UFUNCTION() -void OnUserInsert(const FEventContext& Context, const FUserType& User) -{ - UE_LOG(LogTemp, Log, TEXT("User inserted: %s"), *User.Name); -} - -UFUNCTION() -void OnUserUpdate(const FEventContext& Context, const FUserType& OldUser, const FUserType& NewUser) -{ - UE_LOG(LogTemp, Log, TEXT("User %lld updated: %s -> %s"), - NewUser.Id, *OldUser.Name, *NewUser.Name); -} - -UFUNCTION() -void OnUserDelete(const FEventContext& Context, const FUserType& User) -{ - UE_LOG(LogTemp, Log, TEXT("User deleted: %s"), *User.Name); -} -``` - - - - -These callbacks fire whenever the local cache changes due to subscription updates, providing real-time reactivity. - -## Complete Examples - -For complete working examples, see the language-specific reference pages: - -- [Rust SDK Reference](/sdks/rust) - Comprehensive Rust API documentation -- [C# SDK Reference](/sdks/c-sharp) - C# and Unity-specific patterns -- [TypeScript SDK Reference](/sdks/typescript) - Browser and Node.js examples -- [Unreal SDK Reference](/sdks/unreal) - Unreal Engine C++ and Blueprint patterns - -## Related Documentation - -- [Generating Client Bindings](/sdks/codegen) - How to generate type-safe bindings -- [Connecting to SpacetimeDB](/sdks/connection) - Connection setup and lifecycle -- [Subscriptions](/subscriptions) - Detailed subscription semantics -- [Reducers](/functions/reducers) - Server-side transactional functions -- [Procedures](/functions/procedures) - Server-side functions with external capabilities -- [Tables](/tables) - Database schema and storage diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/_category_.json b/docs/docs/00200-core-concepts/00600-client-sdk-languages/_category_.json deleted file mode 100644 index 51be0a60b..000000000 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/_category_.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "label": "Client SDKs", - "collapsed": true -} \ No newline at end of file diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages.md b/docs/docs/00200-core-concepts/00600-clients.md similarity index 65% rename from docs/docs/00200-core-concepts/00600-client-sdk-languages.md rename to docs/docs/00200-core-concepts/00600-clients.md index 08a0ef22c..e13abaa53 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages.md +++ b/docs/docs/00200-core-concepts/00600-clients.md @@ -1,6 +1,6 @@ --- -title: Overview -slug: /sdks +title: Clients +slug: /clients --- The SpacetimeDB Client SDKs provide a comprehensive interface for building applications that connect to SpacetimeDB [databases](/databases). Client applications can query data, invoke server-side functions, and receive real-time updates as the database state changes. @@ -9,18 +9,18 @@ The SpacetimeDB Client SDKs provide a comprehensive interface for building appli SpacetimeDB provides client SDKs for multiple languages: -- [Rust](/sdks/rust) - [(Quickstart)](/docs/quickstarts/rust) -- [C#](/sdks/c-sharp) - [(Quickstart)](/docs/quickstarts/c-sharp) -- [TypeScript](/sdks/typescript) - [(Quickstart)](/docs/quickstarts/typescript) -- [Unreal](/sdks/unreal) - [(Tutorial)](/tutorials/unreal) +- [Rust](/clients/rust) - [(Quickstart)](/docs/quickstarts/rust) +- [C#](/clients/c-sharp) - [(Quickstart)](/docs/quickstarts/c-sharp) +- [TypeScript](/clients/typescript) - [(Quickstart)](/docs/quickstarts/typescript) +- [Unreal](/clients/unreal) - [(Tutorial)](/tutorials/unreal) ## Getting Started To build a client application with SpacetimeDB: -1. **[Generate client bindings](/sdks/codegen)** - Use `spacetime generate` to create type-safe bindings for your [database](/databases) -2. **[Connect to your database](/sdks/connection)** - Establish a WebSocket connection to SpacetimeDB -3. **[Use the SDK API](/sdks/api)** - Subscribe to data, invoke functions, and register callbacks +1. **[Generate client bindings](/clients/codegen)** - Use `spacetime generate` to create type-safe bindings for your [database](/databases) +2. **[Connect to your database](/clients/connection)** - Establish a WebSocket connection to SpacetimeDB +3. **[Use the SDK API](/clients/api)** - Subscribe to data, invoke functions, and register callbacks ## Core Capabilities @@ -28,11 +28,11 @@ To build a client application with SpacetimeDB: The SDKs handle establishing and maintaining WebSocket connections to SpacetimeDB servers. Connections support authentication via tokens (for example, from [SpacetimeAuth](./00500-authentication/00100-spacetimeauth/index.md)) and provide lifecycle callbacks for connect, disconnect, and error events. -See [Connecting to SpacetimeDB](/sdks/connection) for details. +See [Connecting to SpacetimeDB](/clients/connection) for details. ### Client-Side Data Cache -Each client maintains a local cache of database rows through [subscriptions](/subscriptions). Clients define which data they need using SQL queries, and SpacetimeDB automatically synchronizes changes to the subscribed data. The local cache can be queried without network round-trips, providing fast access to frequently-read data. +Each client maintains a local cache of database rows through [subscriptions](/clients/subscriptions). Clients define which data they need using typed query builders (or raw SQL when needed), and SpacetimeDB automatically synchronizes changes to the subscribed data. The local cache can be queried without network round-trips, providing fast access to frequently-read data. ### Real-Time Updates @@ -52,7 +52,7 @@ Clients can invoke server-side functions to modify data or perform operations: ### Type Safety -The [generated client bindings](/sdks/codegen) provide compile-time type safety between your client and server code. Table schemas, function signatures, and return types are all reflected in the generated code, catching errors before runtime. +The [generated client bindings](/clients/codegen) provide compile-time type safety between your client and server code. Table schemas, function signatures, and return types are all reflected in the generated code, catching errors before runtime. ## Choosing a Language @@ -80,14 +80,14 @@ The functionality of the SDKs remains consistent across languages, so transition New to SpacetimeDB client development? Follow this progression: -1. **[Generate Client Bindings](/sdks/codegen)** - Create type-safe interfaces from your module -2. **[Connect to SpacetimeDB](/sdks/connection)** - Establish a connection and understand the lifecycle -3. **[Use the SDK API](/sdks/api)** - Learn about subscriptions, reducers, and callbacks -4. **Language Reference** - Dive into language-specific details: [Rust](/sdks/rust), [C#](/sdks/c-sharp), [TypeScript](/sdks/typescript) +1. **[Generate Client Bindings](/clients/codegen)** - Create type-safe interfaces from your module +2. **[Connect to SpacetimeDB](/clients/connection)** - Establish a connection and understand the lifecycle +3. **[Use the SDK API](/clients/api)** - Learn about subscriptions, reducers, and callbacks +4. **Language Reference** - Dive into language-specific details: [Rust](/clients/rust), [C#](/clients/c-sharp), [TypeScript](/clients/typescript) ## Next Steps - Follow a **Quickstart guide** [Rust](/quickstarts/rust), [C#](/quickstarts/c-sharp), or [TypeScript](/quickstarts/typescript) to build your first client - Learn about [Databases](/databases) to understand what you're connecting to -- Explore [Subscriptions](/subscriptions) for efficient data synchronization +- Explore [Subscriptions](/clients/subscriptions) for efficient data synchronization - Review [Reducers](/functions/reducers) to understand server-side state changes diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00200-codegen.md b/docs/docs/00200-core-concepts/00600-clients/00200-codegen.md similarity index 97% rename from docs/docs/00200-core-concepts/00600-client-sdk-languages/00200-codegen.md rename to docs/docs/00200-core-concepts/00600-clients/00200-codegen.md index 2451c973e..249bd9a97 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00200-codegen.md +++ b/docs/docs/00200-core-concepts/00600-clients/00200-codegen.md @@ -1,6 +1,6 @@ --- title: Generating Client Bindings -slug: /sdks/codegen +slug: /clients/codegen --- import Tabs from '@theme/Tabs'; @@ -361,9 +361,9 @@ If you're actively developing and testing changes, consider adding `spacetime ge Once you've generated the bindings, you're ready to connect to your database and start interacting with it. See: -- [Connecting to SpacetimeDB](/sdks/connection) for establishing a connection -- [SDK API Reference](/sdks/api) for using the generated bindings -- Language-specific references: [Rust](/sdks/rust), [C#](/sdks/c-sharp), [TypeScript](/sdks/typescript), [Unreal](/sdks/unreal) +- [Connecting to SpacetimeDB](/clients/connection) for establishing a connection +- [SDK API Reference](/clients/api) for using the generated bindings +- Language-specific references: [Rust](/clients/rust), [C#](/clients/c-sharp), [TypeScript](/clients/typescript), [Unreal](/clients/unreal) ## Troubleshooting diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00300-connection.md b/docs/docs/00200-core-concepts/00600-clients/00300-connection.md similarity index 94% rename from docs/docs/00200-core-concepts/00600-client-sdk-languages/00300-connection.md rename to docs/docs/00200-core-concepts/00600-clients/00300-connection.md index 73f1005eb..1abcf241f 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00300-connection.md +++ b/docs/docs/00200-core-concepts/00600-clients/00300-connection.md @@ -1,18 +1,18 @@ --- title: Connecting to SpacetimeDB -slug: /sdks/connection +slug: /clients/connection --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -After [generating client bindings](/sdks/codegen) for your module, you can establish a connection to your SpacetimeDB [database](/databases) from your client application. The `DbConnection` type provides a persistent WebSocket connection that enables real-time communication with the server. +After [generating client bindings](/clients/codegen) for your module, you can establish a connection to your SpacetimeDB [database](/databases) from your client application. The `DbConnection` type provides a persistent WebSocket connection that enables real-time communication with the server. ## Prerequisites Before connecting, ensure you have: -1. [Generated client bindings](/sdks/codegen) for your module +1. [Generated client bindings](/clients/codegen) for your module 2. A published database running on SpacetimeDB (local or on [MainCloud](/how-to/deploy/maincloud)) 3. The database's URI and name or identity @@ -454,13 +454,13 @@ The [identity](/intro/key-architecture#identity) persists across connections and Now that you have a connection established, you can: -- [Use the SDK API](/sdks/api) to interact with tables, invoke reducers, and subscribe to data +- [Use the SDK API](/clients/api) to interact with tables, invoke reducers, and subscribe to data - Register callbacks for observing database changes - Call reducers and procedures on the server For language-specific details, see: -- [Rust SDK Reference](/sdks/rust) -- [C# SDK Reference](/sdks/c-sharp) -- [TypeScript SDK Reference](/sdks/typescript) -- [Unreal SDK Reference](/sdks/unreal) +- [Rust SDK Reference](/clients/rust) +- [C# SDK Reference](/clients/c-sharp) +- [TypeScript SDK Reference](/clients/typescript) +- [Unreal SDK Reference](/clients/unreal) diff --git a/docs/docs/00200-core-concepts/00600-clients/00400-sdk-api.md b/docs/docs/00200-core-concepts/00600-clients/00400-sdk-api.md new file mode 100644 index 000000000..e2a415c8f --- /dev/null +++ b/docs/docs/00200-core-concepts/00600-clients/00400-sdk-api.md @@ -0,0 +1,193 @@ +--- +title: SDK API Overview +slug: /clients/api +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + +The SpacetimeDB client SDKs provide a comprehensive API for interacting with your [database](/databases). After [generating client bindings](/clients/codegen) and [establishing a connection](/clients/connection), you can query data, invoke server functions, and observe real-time changes. + +This page describes the core concepts and patterns that apply across all client SDKs. For language-specific details and complete API documentation, see the reference pages for [Rust](/clients/rust), [C#](/clients/c-sharp), [TypeScript](/clients/typescript), or [Unreal Engine](/clients/unreal). + +## Prerequisites + +Before using the SDK API, you must: + +1. [Generate client bindings](/clients/codegen) using `spacetime generate` +2. [Create a connection](/clients/connection) to your database + +## Subscriptions + +Subscriptions replicate a subset of the database to your client, maintaining a local cache that automatically updates as the server state changes. Clients should subscribe to the data they need, then query the local cache. + +Typical flow: + +1. Create a subscription with the SDK builder API +2. Wait for `onApplied`/`OnApplied` to know initial rows are present +3. Read from the local cache and register callbacks +4. Unsubscribe when the data is no longer needed + +For lifecycle guarantees and semantics, see [Subscriptions](/clients/subscriptions) and [Subscription Semantics](/clients/subscriptions/semantics). + +### Example + + + + +```typescript +import { tables } from './module_bindings'; + +const handle = conn + .subscriptionBuilder() + .onApplied(ctx => { + console.log(`Ready with ${ctx.db.user.count()} users`); + }) + .onError((ctx, error) => { + console.error(`Subscription failed: ${error}`); + }) + .subscribe([tables.user.where(r => r.online.eq(true))]); +``` + + + + +```csharp +var handle = conn + .SubscriptionBuilder() + .OnApplied(ctx => + { + Console.WriteLine($"Ready with {ctx.Db.User.Count} users"); + }) + .OnError((ctx, error) => + { + Console.WriteLine($"Subscription failed: {error}"); + }) + .AddQuery(q => q.From.User()) + .Subscribe(); +``` + + + + +```rust +let handle = conn + .subscription_builder() + .on_applied(|ctx| { + println!("Ready with {} users", ctx.db().user().count()); + }) + .on_error(|_ctx, error| { + eprintln!("Subscription failed: {}", error); + }) + .add_query(|q| q.from.user()) + .subscribe(); +``` + + + + +```cpp +TArray Queries = { TEXT("SELECT * FROM user") }; + +USubscriptionHandle* Handle = Conn->SubscriptionBuilder() + ->OnApplied(AppliedDelegate) + ->OnError(ErrorDelegate) + ->Subscribe(Queries); +``` + + + + +## Querying the Local Cache + +After a subscription is applied, reads are local and do not require network round-trips. + + + + +```typescript +const userCount = conn.db.user.count(); +const user = conn.db.user.name.find('Alice'); +``` + + + + +```csharp +var userCount = conn.Db.User.Count; +var user = conn.Db.User.Name.Find("Alice"); +``` + + + + +```rust +let user_count = conn.db().user().count(); +let user = conn.db().user().name().find("Alice"); +``` + + + + +```cpp +int32 UserCount = Conn->Db->User->Count(); +FUserType User = Conn->Db->User->Name->Find(TEXT("Alice")); +``` + + + + +## Reacting to Cache Changes + +Use row callbacks to react when subscribed rows are inserted, updated, or deleted. + + + + +```typescript +conn.db.user.onInsert((ctx, row) => {}); +conn.db.user.onUpdate((ctx, oldRow, newRow) => {}); +conn.db.user.onDelete((ctx, row) => {}); +``` + + + + +```csharp +conn.Db.User.OnInsert += (ctx, row) => {}; +conn.Db.User.OnUpdate += (ctx, oldRow, newRow) => {}; +conn.Db.User.OnDelete += (ctx, row) => {}; +``` + + + + +```rust +conn.db().user().on_insert(|ctx, row| {}); +conn.db().user().on_update(|ctx, old_row, new_row| {}); +conn.db().user().on_delete(|ctx, row| {}); +``` + + + + +```cpp +Conn->Db->User->OnInsert.AddDynamic(this, &AMyActor::OnUserInsert); +Conn->Db->User->OnUpdate.AddDynamic(this, &AMyActor::OnUserUpdate); +Conn->Db->User->OnDelete.AddDynamic(this, &AMyActor::OnUserDelete); +``` + + + + +## Canonical API References + +- [Subscriptions](/clients/subscriptions) - Lifecycle, usage patterns, and semantics +- [Subscription Semantics](/clients/subscriptions/semantics) - Detailed consistency and ordering behavior +- [TypeScript Reference](/clients/typescript#subscribe-to-queries) - `SubscriptionBuilder`, `SubscriptionHandle`, query builder API +- [C# Reference](/clients/c-sharp#subscribe-to-queries) - `SubscriptionBuilder`, `SubscriptionHandle` +- [C# Query Builder API](/clients/c-sharp#query-builder-api) - Typed subscription query builder +- [Rust Reference](/clients/rust#subscribe-to-queries) - `SubscriptionBuilder`, `SubscriptionHandle` +- [Rust Query Builder API](/clients/rust#query-builder-api) - Typed subscription query builder +- [Unreal Reference](/clients/unreal#subscriptions) - Unreal subscription APIs diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00500-rust-reference.md b/docs/docs/00200-core-concepts/00600-clients/00500-rust-reference.md similarity index 88% rename from docs/docs/00200-core-concepts/00600-client-sdk-languages/00500-rust-reference.md rename to docs/docs/00200-core-concepts/00600-clients/00500-rust-reference.md index 894914cb5..711d50ca7 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00500-rust-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00500-rust-reference.md @@ -1,7 +1,7 @@ --- title: Rust Reference toc_max_heading_level: 6 -slug: /sdks/rust +slug: /clients/rust --- @@ -9,9 +9,9 @@ The SpacetimeDB client SDK for Rust contains all the tools you need to build nat Before diving into the reference, you may want to review: -- [Generating Client Bindings](/sdks/codegen) - How to generate Rust bindings from your module -- [Connecting to SpacetimeDB](/sdks/connection) - Establishing and managing connections -- [SDK API Reference](/sdks/api) - Core concepts that apply across all SDKs +- [Generating Client Bindings](/clients/codegen) - How to generate Rust bindings from your module +- [Connecting to SpacetimeDB](/clients/connection) - Establishing and managing connections +- [SDK API Reference](/clients/api) - Core concepts that apply across all SDKs | Name | Description | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | @@ -23,6 +23,7 @@ Before diving into the reference, you may want to review: | [`ReducerEventContext` type](#type-reducereventcontext) | [`DbContext`](#trait-dbcontext) available in [reducer callbacks](#observe-and-invoke-reducers). | | [`SubscriptionEventContext` type](#type-subscriptioneventcontext) | [`DbContext`](#trait-dbcontext) available in [subscription-related callbacks](#subscribe-to-queries). | | [`ErrorContext` type](#type-errorcontext) | [`DbContext`](#trait-dbcontext) available in error-related callbacks. | +| [Query Builder API](#query-builder-api) | Type-safe query builder for typed subscription queries. | | [Access the client cache](#access-the-client-cache) | Make local queries against subscribed rows, and register [row callbacks](#callback-on_insert) to run when subscribed rows change. | | [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | | [Identify a client](#identify-a-client) | Types for identifying users and client connections. | @@ -265,7 +266,7 @@ The `DbContext` trait is implemented by connections and contexts to _every_ modu | [`db` method](#method-db) | Trait-generic alternative to the `db` field of `DbConnection`. | | [`reducers` method](#method-reducers) | Trait-generic alternative to the `reducers` field of `DbConnection`. | | [`disconnect` method](#method-disconnect) | End the connection. | -| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Subscribe to queries](#subscribe-to-queries) | Register subscription queries to receive updates about matching rows. | | [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | ### Trait `RemoteDbContext` @@ -330,7 +331,8 @@ Gracefully close the `DbConnection`. Returns an `Err` if the connection is alrea | Name | Description | | ------------------------------------------------------- | ----------------------------------------------------------- | | [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | -| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | +| [`TypedSubscriptionBuilder` type](#type-typedsubscriptionbuilder) | Builder for typed query subscriptions. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscription. | #### Type `SubscriptionBuilder` @@ -343,7 +345,8 @@ spacetimedb_sdk::SubscriptionBuilder | [`ctx.subscription_builder()` constructor](#constructor-ctxsubscription_builder) | Begin configuring a new subscription. | | [`on_applied` callback](#callback-on_applied) | Register a callback to run when matching rows become available. | | [`on_error` callback](#callback-on_error) | Register a callback to run if the subscription fails. | -| [`subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more queries. | +| [`add_query` method](#method-add_query) | Build a typed subscription query without writing query strings. | | [`subscribe_to_all_tables` method](#method-subscribe_to_all_tables) | Convenience method to subscribe to the entire database. | ##### Constructor `ctx.subscription_builder()` @@ -388,6 +391,29 @@ Subscribe to a set of queries. `queries` should be a string or an array, vec or See [the SpacetimeDB SQL Reference](/reference/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. +For typed query subscriptions, use [`add_query`](#method-add_query). + +##### Method `add_query` + +```rust +impl SubscriptionBuilder { + fn add_query( + self, + build: impl Fn(M::QueryBuilder) -> impl Query, + ) -> TypedSubscriptionBuilder; +} +``` + +Start a typed query subscription. Once a typed query is added, continue with typed queries on `TypedSubscriptionBuilder` and finish with `subscribe()`. + +```rust +let handle = conn + .subscription_builder() + .add_query(|q| q.from.user()) + .add_query(|q| q.from.message()) + .subscribe(); +``` + ##### Method `subscribe_to_all_tables` ```rust @@ -398,6 +424,136 @@ impl SubscriptionBuilder { Subscribe to all rows from all public tables. This method is provided as a convenience for simple clients. The subscription initiated by `subscribe_to_all_tables` cannot be canceled after it is initiated. You should [`subscribe` to specific queries](#method-subscribe) if you need fine-grained control over the lifecycle of your subscriptions. +#### Type `TypedSubscriptionBuilder` + +```rust +TypedSubscriptionBuilder +``` + +| Name | Description | +| -------------------------------------------------------------- | ----------- | +| [`add_query` method](#method-add_query-typedsubscriptionbuilder) | Add another typed query to the same subscription. | +| [`subscribe` method](#method-subscribe-typedsubscriptionbuilder) | Subscribe to all typed queries added so far. | + +##### Method `add_query` (TypedSubscriptionBuilder) + +```rust +impl TypedSubscriptionBuilder { + fn add_query( + self, + build: impl Fn(M::QueryBuilder) -> impl Query, + ) -> Self; +} +``` + +Add another typed query. This keeps all added queries grouped under one returned `SubscriptionHandle`. + +##### Method `subscribe` (TypedSubscriptionBuilder) + +```rust +impl TypedSubscriptionBuilder { + fn subscribe(self) -> M::SubscriptionHandle; +} +``` + +Subscribe to the set of typed queries that were added to the builder. + +## Query Builder API + +The Rust SDK provides a type-safe query builder for subscriptions. You use it through `subscription_builder().add_query(...)`. + +### Entry Point + +Typed query builders are created from generated table accessors under `QueryBuilder.from`. + +```rust +let handle = conn + .subscription_builder() + .add_query(|q| q.from.user()) + .subscribe(); +``` + +### Building Queries with `where` / `filter` + +Rust uses the raw identifier form `r#where(...)` because `where` is a keyword. `filter(...)` is an alias. Chaining multiple `r#where`/`filter` calls combines conditions with logical `AND`. + +```rust +// All users +q.from.user() + +// Filtered users +q.from.user().r#where(|u| u.online.eq(true)) +q.from.user().filter(|u| u.name.ne("Anonymous")) + +// Chained filters (AND semantics) +q.from + .user() + .r#where(|u| u.score.gte(1000u64)) + .filter(|u| u.level.gte(10u32)) +``` + +### Comparison Operators + +| Operator | Description | Example | +| --- | --- | --- | +| `eq` | Equal to | `u.online.eq(true)` | +| `ne` | Not equal to | `u.name.ne("BOT")` | +| `lt` | Less than | `u.level.lt(10u32)` | +| `lte` | Less than or equal to | `u.level.lte(10u32)` | +| `gt` | Greater than | `u.score.gt(1000u64)` | +| `gte` | Greater than or equal to | `u.score.gte(1000u64)` | + +### Boolean Combinators + +Combine conditions with `and`, `or`, and `not`: + +```rust +q.from.user().r#where(|u| u.level.gte(5u32).and(u.level.lt(10u32))) +q.from.user().r#where(|u| u.online.eq(true).or(u.name.eq("Admin"))) +q.from.user().r#where(|u| u.banned.eq(true).not()) +``` + +### Semijoins + +Semijoins match rows across two tables and return rows from one side: + +- `left_semijoin(...)` returns rows from the left side that match at least one row on the right. +- `right_semijoin(...)` returns rows from the right side that match at least one row on the left. +- The join predicate uses indexed columns (`IxCols`) and compares one indexed column from each side with `eq`. +- Filters before a semijoin apply to the pre-join source side. Filters after a semijoin apply to the returned side. + +```rust +let handle = conn + .subscription_builder() + .add_query(|q| { + q.from + .player() + .r#where(|p| p.score.gte(1000u64)) + .left_semijoin(q.from.player_level(), |p, pl| p.id.eq(pl.player_id)) + .r#where(|p| p.online.eq(true)) + }) + .add_query(|q| { + q.from + .player() + .r#where(|p| p.score.gte(1000u64)) + .right_semijoin(q.from.player_level(), |p, pl| p.id.eq(pl.player_id)) + .r#where(|pl| pl.level.gte(10u32)) + }) + .subscribe(); +``` + +### Using Query Builders with Subscriptions + +`add_query` accepts a builder function returning `impl Query`. You can add multiple typed queries and subscribe once. + +```rust +let handle = conn + .subscription_builder() + .add_query(|q| q.from.user().r#where(|u| u.online.eq(true))) + .add_query(|q| q.from.message().r#where(|m| m.channel_id.eq(1u32))) + .subscribe(); +``` + #### Type `SubscriptionHandle` ```rust diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00600-csharp-reference.md b/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md similarity index 88% rename from docs/docs/00200-core-concepts/00600-client-sdk-languages/00600-csharp-reference.md rename to docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md index f81a3f413..1ddb8a91c 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00600-csharp-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00600-csharp-reference.md @@ -1,7 +1,7 @@ --- title: C# Reference toc_max_heading_level: 6 -slug: /sdks/c-sharp +slug: /clients/c-sharp --- @@ -9,9 +9,9 @@ The SpacetimeDB client for C# contains all the tools you need to build native cl Before diving into the reference, you may want to review: -- [Generating Client Bindings](/sdks/codegen) - How to generate C# bindings from your module -- [Connecting to SpacetimeDB](/sdks/connection) - Establishing and managing connections (important: C# requires manual connection advancement!) -- [SDK API Reference](/sdks/api) - Core concepts that apply across all SDKs +- [Generating Client Bindings](/clients/codegen) - How to generate C# bindings from your module +- [Connecting to SpacetimeDB](/clients/connection) - Establishing and managing connections (important: C# requires manual connection advancement!) +- [SDK API Reference](/clients/api) - Core concepts that apply across all SDKs | Name | Description | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | @@ -23,6 +23,7 @@ Before diving into the reference, you may want to review: | [`ReducerEventContext` type](#type-reducereventcontext) | Implements [`IDbContext`](#interface-idbcontext) for [reducer callbacks](#observe-and-invoke-reducers). | | [`SubscriptionEventContext` type](#type-subscriptioneventcontext) | Implements [`IDbContext`](#interface-idbcontext) for [subscription callbacks](#subscribe-to-queries). | | [`ErrorContext` type](#type-errorcontext) | Implements [`IDbContext`](#interface-idbcontext) for error-related callbacks. | +| [Query Builder API](#query-builder-api) | Type-safe query builder for typed subscription queries. | | [Access the client cache](#access-the-client-cache) | Access to your local view of the database. | | [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | | [Identify a client](#identify-a-client) | Types for identifying users and client connections. | @@ -253,7 +254,7 @@ The `IDbContext` interface is implemented by connections and contexts to _every_ | [`Db` method](#method-db) | Provides access to the subscribed view of the remote database's tables. | | [`Reducers` method](#method-reducers) | Provides access to reducers exposed by the remote module. | | [`Disconnect` method](#method-disconnect) | End the connection. | -| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Subscribe to queries](#subscribe-to-queries) | Register subscription queries to receive updates about matching rows. | | [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | ### Interface `IRemoteDbContext` @@ -317,7 +318,8 @@ Gracefully close the `DbConnection`. Throws an error if the connection is alread | Name | Description | | ------------------------------------------------------- | ----------------------------------------------------------- | | [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | -| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | +| [`TypedSubscriptionBuilder` type](#type-typedsubscriptionbuilder) | Builder for typed query subscriptions. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscription. | #### Type `SubscriptionBuilder` @@ -326,7 +328,8 @@ Gracefully close the `DbConnection`. Throws an error if the connection is alread | [`ctx.SubscriptionBuilder()` constructor](#constructor-ctxsubscriptionbuilder) | Begin configuring a new subscription. | | [`OnApplied` callback](#callback-onapplied) | Register a callback to run when matching rows become available. | | [`OnError` callback](#callback-onerror) | Register a callback to run if the subscription fails. | -| [`Subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`Subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more queries. | +| [`AddQuery` method](#method-addquery) | Build a typed subscription query without writing query strings. | | [`SubscribeToAllTables` method](#method-subscribetoalltables) | Convenience method to subscribe to the entire database. | ##### Constructor `ctx.SubscriptionBuilder()` @@ -375,6 +378,29 @@ Subscribe to a set of queries. `queries` should be an array of SQL query strings See [the SpacetimeDB SQL Reference](/reference/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. +For typed query subscriptions, use [`AddQuery`](#method-addquery). + +##### Method `AddQuery` + +```csharp +class SubscriptionBuilder +{ + public TypedSubscriptionBuilder AddQuery( + Func> build + ); +} +``` + +Start a typed query subscription. Once a typed query is added, continue with typed queries on `TypedSubscriptionBuilder` and finish with `Subscribe()`. + +```csharp +var handle = conn + .SubscriptionBuilder() + .AddQuery(q => q.From.User()) + .AddQuery(q => q.From.Message()) + .Subscribe(); +``` + ##### Method `SubscribeToAllTables` ```csharp @@ -386,6 +412,126 @@ class SubscriptionBuilder Subscribe to all rows from all public tables. This method is provided as a convenience for simple clients. The subscription initiated by `SubscribeToAllTables` cannot be canceled after it is initiated. You should [`subscribe` to specific queries](#method-subscribe) if you need fine-grained control over the lifecycle of your subscriptions. +#### Type `TypedSubscriptionBuilder` + +| Name | Description | +| ------------------------------------------------ | ----------- | +| [`AddQuery` method](#method-addquery-typedsubscriptionbuilder) | Add another typed query to the same subscription. | +| [`Subscribe` method](#method-subscribe-typedsubscriptionbuilder) | Subscribe to all typed queries added so far. | + +##### Method `AddQuery` (TypedSubscriptionBuilder) + +```csharp +class TypedSubscriptionBuilder +{ + public TypedSubscriptionBuilder AddQuery( + Func> build + ); +} +``` + +Add another typed query. This keeps all added queries grouped under one returned `SubscriptionHandle`. + +##### Method `Subscribe` (TypedSubscriptionBuilder) + +```csharp +class TypedSubscriptionBuilder +{ + public SubscriptionHandle Subscribe(); +} +``` + +Subscribe to the set of typed queries that were added to the builder. + +## Query Builder API + +The C# SDK provides a type-safe query builder for subscriptions. You use it through `SubscriptionBuilder.AddQuery(...)` and `TypedSubscriptionBuilder.AddQuery(...)`. + +### Entry Point + +Typed query builders are created from generated table accessors under `QueryBuilder.From`. + +```csharp +var handle = conn + .SubscriptionBuilder() + .AddQuery(q => q.From.User()) + .Subscribe(); +``` + +### Building Queries with `Where` / `Filter` + +Each generated table accessor supports both `Where(...)` and `Filter(...)`. They are equivalent. Chaining multiple `Where`/`Filter` calls combines conditions with logical `AND`. + +```csharp +// All users +q.From.User() + +// Filtered users +q.From.User().Where(u => u.Online.Eq(true)) +q.From.User().Filter(u => u.Name.Neq("Anonymous")) + +// Chained filters (AND semantics) +q.From.User() + .Where(u => u.Score.Gte(1000UL)) + .Filter(u => u.Level.Gte(10U)) +``` + +### Comparison Operators + +| Operator | Description | Example | +| --- | --- | --- | +| `Eq` | Equal to | `u.Online.Eq(true)` | +| `Neq` | Not equal to | `u.Name.Neq("BOT")` | +| `Lt` | Less than | `u.Level.Lt(10U)` | +| `Lte` | Less than or equal to | `u.Level.Lte(10U)` | +| `Gt` | Greater than | `u.Score.Gt(1000UL)` | +| `Gte` | Greater than or equal to | `u.Score.Gte(1000UL)` | + +### Boolean Combinators + +Combine conditions with `And`, `Or`, and `Not`: + +```csharp +q.From.User().Where(u => u.Level.Gte(5U).And(u.Level.Lt(10U))) +q.From.User().Where(u => u.Online.Eq(true).Or(u.Name.Eq("Admin"))) +q.From.User().Where(u => u.Banned.Eq(true).Not()) +``` + +### Semijoins + +Semijoins match rows across two tables and return rows from one side: + +- `LeftSemijoin(...)` returns rows from the left side that match at least one row on the right. +- `RightSemijoin(...)` returns rows from the right side that match at least one row on the left. +- The join predicate uses indexed columns (`IxCols`) and must compare one indexed column from each side with `Eq`. +- Filters before a semijoin apply to the pre-join source side. Filters after a semijoin apply to the returned side. + +```csharp +var handle = conn + .SubscriptionBuilder() + .AddQuery(q => q.From.Player() + .Where(p => p.Score.Gte(1000UL)) + .LeftSemijoin(q.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId)) + .Where(p => p.Online.Eq(true))) + .AddQuery(q => q.From.Player() + .Where(p => p.Score.Gte(1000UL)) + .RightSemijoin(q.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId)) + .Where(pl => pl.Level.Gte(10U))) + .Subscribe(); +``` + +### Using Query Builders with Subscriptions + +`AddQuery` accepts a builder function that returns an `IQuery`. You can add multiple typed queries and subscribe once. + +```csharp +var handle = conn + .SubscriptionBuilder() + .AddQuery(q => q.From.User().Where(u => u.Online.Eq(true))) + .AddQuery(q => q.From.Message().Where(m => m.ChannelId.Eq(1U))) + .Subscribe(); +``` + #### Type `SubscriptionHandle` A `SubscriptionHandle` represents a subscribed query or a group of subscribed queries. diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md b/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md similarity index 95% rename from docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md rename to docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md index 5c1eeb429..e16825431 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00700-typescript-reference.md @@ -1,6 +1,6 @@ --- title: TypeScript Reference -slug: /sdks/typescript +slug: /clients/typescript --- @@ -8,9 +8,9 @@ The SpacetimeDB client SDK for TypeScript contains all the tools you need to bui Before diving into the reference, you may want to review: -- [Generating Client Bindings](/sdks/codegen) - How to generate TypeScript bindings from your module -- [Connecting to SpacetimeDB](/sdks/connection) - Establishing and managing connections -- [SDK API Reference](/sdks/api) - Core concepts that apply across all SDKs +- [Generating Client Bindings](/clients/codegen) - How to generate TypeScript bindings from your module +- [Connecting to SpacetimeDB](/clients/connection) - Establishing and managing connections +- [SDK API Reference](/clients/api) - Core concepts that apply across all SDKs | Name | Description | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | @@ -260,7 +260,7 @@ The `DbContext` interface is implemented by connections and contexts to _every_ | [`db` field](#field-db) | Access subscribed rows of tables and register row callbacks. | | [`reducers` field](#field-reducers) | Request reducer invocations and register reducer callbacks. | | [`disconnect` method](#method-disconnect) | End the connection. | -| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Subscribe to queries](#subscribe-to-queries) | Register subscription queries to receive updates about matching rows. | | [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | #### Field `db` @@ -298,7 +298,7 @@ Gracefully close the `DbConnection`. Throws an error if the connection is alread | Name | Description | | ------------------------------------------------------- | ----------------------------------------------------------- | | [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | -| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscription. | #### Type `SubscriptionBuilder` @@ -311,7 +311,7 @@ SubscriptionBuilder; | [`ctx.subscriptionBuilder()` constructor](#constructor-ctxsubscriptionbuilder) | Begin configuring a new subscription. | | [`onApplied` callback](#callback-onapplied) | Register a callback to run when matching rows become available. | | [`onError` callback](#callback-onerror) | Register a callback to run if the subscription fails. | -| [`subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more queries. | | [`subscribeToAllTables` method](#method-subscribetoalltables) | Convenience method to subscribe to the entire database. | ##### Constructor `ctx.subscriptionBuilder()` @@ -322,7 +322,7 @@ interface DbContext { } ``` -Subscribe to queries by calling `ctx.subscription_builder()` and chaining configuration methods, then calling `.subscribe(queries)`. +Subscribe to queries by calling `ctx.subscriptionBuilder()` and chaining configuration methods, then calling `.subscribe(queries)`. ##### Callback `onApplied` @@ -360,7 +360,7 @@ class SubscriptionBuilder { } ``` -Subscribe to a set of queries. You can pass raw SQL strings, or use [query builders](#query-builder-api) for type-safe, auto-completing queries. +Subscribe to a set of queries. Use [query builders](#query-builder-api) as the default for type-safe, auto-completing queries. Raw SQL strings are also supported for advanced use cases. ```typescript import { tables } from './module_bindings'; @@ -371,13 +371,9 @@ conn.subscriptionBuilder().subscribe([tables.user, tables.message]); conn.subscriptionBuilder().subscribe( tables.user.where(r => r.online.eq(true)) ); - -// Raw SQL — still supported -conn.subscriptionBuilder().subscribe('SELECT * FROM user'); -conn.subscriptionBuilder().subscribe(['SELECT * FROM user', 'SELECT * FROM message']); ``` -See [the SpacetimeDB SQL Reference](/reference/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. +For raw SQL subscription syntax, see [the SpacetimeDB SQL Reference](/reference/sql#subscriptions). ##### Method `subscribeToAllTables` @@ -391,7 +387,7 @@ Subscribe to all rows from all public tables. This method is provided as a conve ## Query Builder API -The TypeScript SDK provides a type-safe query builder as an alternative to raw SQL strings. Query builders give you auto-completion and compile-time type checking for your subscription queries. +The TypeScript SDK provides a type-safe query builder as the recommended way to define subscriptions. Query builders give you auto-completion and compile-time type checking. ### The `tables` export @@ -400,8 +396,8 @@ Your generated `module_bindings` exports a `tables` object. Each property on `ta ```typescript import { tables } from './module_bindings'; -// `tables.user` is a query builder for `SELECT * FROM user` -// `tables.message` is a query builder for `SELECT * FROM message` +// `tables.user` selects all rows from `user` +// `tables.message` selects all rows from `message` ``` ### Building queries with `where` @@ -450,6 +446,27 @@ tables.user.where(r => or(r.online.eq(true), r.name.eq('Admin'))) tables.user.where(r => not(r.online.eq(true))) ``` +### Semijoins + +Semijoins match rows across two tables and return rows from one side: + +- `leftSemijoin(...)` returns rows from the left side that match at least one row on the right. +- `rightSemijoin(...)` returns rows from the right side that match at least one row on the left. +- The join predicate is built from indexed row expressions and should compare indexed columns. +- Filters before a semijoin apply to the pre-join source side. Filters after a semijoin apply to the returned side. + +```typescript +const leftSide = tables.player + .where(p => p.score.gte(1000)) + .leftSemijoin(tables.playerLevel, (p, pl) => p.id.eq(pl.playerId)) + .where(p => p.online.eq(true)); + +const rightSide = tables.player + .where(p => p.score.gte(1000)) + .rightSemijoin(tables.playerLevel, (p, pl) => p.id.eq(pl.playerId)) + .where(pl => pl.level.gte(10)); +``` + ### Using query builders with subscriptions Query builders can be passed directly to `subscribe`: @@ -470,9 +487,6 @@ conn.subscriptionBuilder().subscribe([ tables.user, tables.message, ]); - -// Raw SQL is still supported -conn.subscriptionBuilder().subscribe('SELECT * FROM user'); ``` #### Type `SubscriptionHandle` diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00800-unreal-reference.md b/docs/docs/00200-core-concepts/00600-clients/00800-unreal-reference.md similarity index 98% rename from docs/docs/00200-core-concepts/00600-client-sdk-languages/00800-unreal-reference.md rename to docs/docs/00200-core-concepts/00600-clients/00800-unreal-reference.md index c0881d74d..d9e05be59 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00800-unreal-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00800-unreal-reference.md @@ -1,6 +1,6 @@ --- title: Unreal Reference -slug: /sdks/unreal +slug: /clients/unreal --- @@ -8,9 +8,9 @@ The SpacetimeDB client for Unreal Engine contains all the tools you need to buil Before diving into the reference, you may want to review: -- [Generating Client Bindings](/sdks/codegen) - How to generate Unreal bindings from your module -- [Connecting to SpacetimeDB](/sdks/connection) - Establishing and managing connections -- [SDK API Reference](/sdks/api) - Core concepts that apply across all SDKs +- [Generating Client Bindings](/clients/codegen) - How to generate Unreal bindings from your module +- [Connecting to SpacetimeDB](/clients/connection) - Establishing and managing connections +- [SDK API Reference](/clients/api) - Core concepts that apply across all SDKs | Name | Description | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | @@ -570,7 +570,7 @@ Subscribe to the provided SQL queries and return a handle for managing the subsc USubscriptionHandle* SubscribeToAllTables(); ``` -Subscribe to all tables in the module (equivalent to `Subscribe({ "SELECT * FROM *" })`). +Subscribe to all public tables in the module. ### Type `USubscriptionHandle` diff --git a/docs/docs/00200-core-concepts/00600-clients/_category_.json b/docs/docs/00200-core-concepts/00600-clients/_category_.json new file mode 100644 index 000000000..b92fd1570 --- /dev/null +++ b/docs/docs/00200-core-concepts/00600-clients/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Clients", + "collapsed": true +} diff --git a/docs/docs/00300-resources/00100-how-to/00600-migrating-to-2.0.md b/docs/docs/00300-resources/00100-how-to/00600-migrating-to-2.0.md index 23b7fcd8e..ca9a3c878 100644 --- a/docs/docs/00300-resources/00100-how-to/00600-migrating-to-2.0.md +++ b/docs/docs/00300-resources/00100-how-to/00600-migrating-to-2.0.md @@ -104,7 +104,7 @@ conn.db.damage_event().on_insert(|ctx, event| { - **Flexibility**: Multiple reducers can insert the same event type. In 1.0, events were tied 1:1 to a specific reducer. - **Transactional**: Events are only published if the transaction commits. In 1.0, workarounds using scheduled reducers were not transactional. - **Row-level security**: RLS rules apply to event tables, so you can control which clients see which events. -- **Queryable**: Event tables are subscribed to with standard SQL, and can be filtered per-client. +- **Queryable**: Event tables can be subscribed to with query builders (or SQL), and can be filtered per-client. ### Event table details @@ -112,7 +112,7 @@ conn.db.damage_event().on_insert(|ctx, event| { - On the client, `count()` always returns 0 and `iter()` is always empty. - Only `on_insert` callbacks are generated (no `on_delete` or `on_update`). - The `event` keyword in `#[table(..., event)]` marks the table as transient. -- Event tables must be subscribed to explicitly (they are excluded from `SELECT * FROM *`). +- Event tables must be subscribed to explicitly (they are excluded from `subscribeToAllTables` / `SubscribeToAllTables` / `subscribe_to_all_tables`). ## Light Mode @@ -189,16 +189,18 @@ The subscription API is largely unchanged: ctx.subscription_builder() .on_applied(|ctx| { /* ... */ }) .on_error(|ctx, error| { /* ... */ }) - .subscribe(["SELECT * FROM my_table"]); + .add_query(|q| q.from.my_table()) + .subscribe(); ``` Note that subscribing to event tables requires an explicit query: ```rust -// Event tables are excluded from SELECT * FROM *, so subscribe explicitly: +// Event tables are excluded from subscribe_to_all_tables(), so subscribe explicitly: ctx.subscription_builder() .on_applied(|ctx| { /* ... */ }) - .subscribe(["SELECT * FROM damage_event"]); + .add_query(|q| q.from.damage_event()) + .subscribe(); ``` ## Quick Migration Checklist diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 126c81e56..4fd306fa6 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -60,13 +60,6 @@ const sidebars: SidebarsConfig = { link: { type: 'doc', id: 'core-concepts/tables' }, items: [{ type: 'autogenerated', dirName: '00200-core-concepts/00300-tables' }], }, - { - type: 'category', - label: 'Subscriptions', - collapsed: true, - link: { type: 'doc', id: 'core-concepts/subscriptions' }, - items: [{ type: 'autogenerated', dirName: '00200-core-concepts/00400-subscriptions' }], - }, { type: 'category', label: 'Authentication', @@ -76,10 +69,25 @@ const sidebars: SidebarsConfig = { }, { type: 'category', - label: 'Client SDKs', + label: 'Clients', collapsed: true, - link: { type: 'doc', id: 'core-concepts/client-sdk-languages' }, - items: [{ type: 'autogenerated', dirName: '00200-core-concepts/00600-client-sdk-languages' }], + link: { type: 'doc', id: 'core-concepts/clients' }, + items: [ + { type: 'doc', id: 'core-concepts/clients/codegen' }, + { type: 'doc', id: 'core-concepts/clients/connection' }, + { type: 'doc', id: 'core-concepts/clients/sdk-api' }, + { + type: 'category', + label: 'Subscriptions', + collapsed: true, + link: { type: 'doc', id: 'core-concepts/subscriptions' }, + items: [{ type: 'autogenerated', dirName: '00200-core-concepts/00400-subscriptions' }], + }, + { type: 'doc', id: 'core-concepts/clients/rust-reference' }, + { type: 'doc', id: 'core-concepts/clients/csharp-reference' }, + { type: 'doc', id: 'core-concepts/clients/typescript-reference' }, + { type: 'doc', id: 'core-concepts/clients/unreal-reference' }, + ], }, // Developer Resources section header { diff --git a/templates/basic-cpp/src/main.rs b/templates/basic-cpp/src/main.rs index 0d1df944b..98957a87b 100644 --- a/templates/basic-cpp/src/main.rs +++ b/templates/basic-cpp/src/main.rs @@ -32,7 +32,8 @@ fn main() { conn.subscription_builder() .on_applied(|_ctx| println!("Subscripted to the person table")) .on_error(|_ctx, e| eprintln!("There was an error when subscring to the person table: {e}")) - .subscribe(["SELECT * FROM person"]); + .add_query(|q| q.from.person()) + .subscribe(); // Register a callback for when rows are inserted into the person table conn.db().person().on_insert(|_ctx, person| { diff --git a/templates/basic-rs/src/main.rs b/templates/basic-rs/src/main.rs index 0d1df944b..98957a87b 100644 --- a/templates/basic-rs/src/main.rs +++ b/templates/basic-rs/src/main.rs @@ -32,7 +32,8 @@ fn main() { conn.subscription_builder() .on_applied(|_ctx| println!("Subscripted to the person table")) .on_error(|_ctx, e| eprintln!("There was an error when subscring to the person table: {e}")) - .subscribe(["SELECT * FROM person"]); + .add_query(|q| q.from.person()) + .subscribe(); // Register a callback for when rows are inserted into the person table conn.db().person().on_insert(|_ctx, person| { diff --git a/templates/browser-ts/index.html b/templates/browser-ts/index.html index b1f84f8ff..f482b2744 100644 --- a/templates/browser-ts/index.html +++ b/templates/browser-ts/index.html @@ -90,7 +90,7 @@ conn.subscriptionBuilder() .onApplied(() => renderPeople(conn)) - .subscribe(['SELECT * FROM person']); + .subscribe(tables.person); conn.db.person.onInsert(() => renderPeople(conn)); conn.db.person.onDelete(() => renderPeople(conn)); diff --git a/templates/browser-ts/src/bindings.ts b/templates/browser-ts/src/bindings.ts index bc0e0d44c..dbedf6c6b 100644 --- a/templates/browser-ts/src/bindings.ts +++ b/templates/browser-ts/src/bindings.ts @@ -1,6 +1,7 @@ // Re-export generated bindings as globals for use in script tags -export { DbConnection } from './module_bindings'; +export { DbConnection, tables } from './module_bindings'; -// Make DbConnection available globally -import { DbConnection } from './module_bindings'; +// Make generated bindings available globally +import { DbConnection, tables } from './module_bindings'; (window as any).DbConnection = DbConnection; +(window as any).tables = tables; diff --git a/templates/chat-console-rs/src/main.rs b/templates/chat-console-rs/src/main.rs index bace4c003..99011e44b 100644 --- a/templates/chat-console-rs/src/main.rs +++ b/templates/chat-console-rs/src/main.rs @@ -163,7 +163,9 @@ fn subscribe_to_tables(ctx: &DbConnection) { ctx.subscription_builder() .on_applied(on_sub_applied) .on_error(on_sub_error) - .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); + .add_query(|q| q.from.user()) + .add_query(|q| q.from.message()) + .subscribe(); } // ### Print past messages in order diff --git a/templates/nextjs-ts/lib/spacetimedb-server.ts b/templates/nextjs-ts/lib/spacetimedb-server.ts index 96aa5e2a9..8d0d0d544 100644 --- a/templates/nextjs-ts/lib/spacetimedb-server.ts +++ b/templates/nextjs-ts/lib/spacetimedb-server.ts @@ -1,4 +1,4 @@ -import { DbConnection, Person } from '../src/module_bindings'; +import { DbConnection, Person, tables } from '../src/module_bindings'; import type { Infer } from 'spacetimedb'; const HOST = process.env.SPACETIMEDB_HOST ?? 'wss://maincloud.spacetimedb.com'; @@ -38,7 +38,7 @@ export async function fetchPeople(): Promise { conn.disconnect(); reject(error); }) - .subscribe('SELECT * FROM person'); + .subscribe(tables.person); }) .onConnectError((_ctx, error) => { clearTimeout(timeoutId); diff --git a/templates/nuxt-ts/server/api/people.get.ts b/templates/nuxt-ts/server/api/people.get.ts index 20e6bac69..24a98b533 100644 --- a/templates/nuxt-ts/server/api/people.get.ts +++ b/templates/nuxt-ts/server/api/people.get.ts @@ -1,4 +1,4 @@ -import { DbConnection, type PersonRow } from '../../module_bindings'; +import { DbConnection, tables, type PersonRow } from '../../module_bindings'; import type { Infer } from 'spacetimedb'; const HOST = process.env.SPACETIMEDB_HOST ?? 'ws://localhost:3000'; @@ -29,7 +29,7 @@ export default defineEventHandler(async (): Promise => { conn.disconnect(); reject(error); }) - .subscribe('SELECT * FROM person'); + .subscribe(tables.person); }) .onConnectError((_ctx, error) => { clearTimeout(timeoutId); diff --git a/templates/remix-ts/app/lib/spacetimedb.server.ts b/templates/remix-ts/app/lib/spacetimedb.server.ts index 7af31ba73..16dd6db55 100644 --- a/templates/remix-ts/app/lib/spacetimedb.server.ts +++ b/templates/remix-ts/app/lib/spacetimedb.server.ts @@ -1,4 +1,4 @@ -import { DbConnection, Person } from '../../src/module_bindings'; +import { DbConnection, Person, tables } from '../../src/module_bindings'; import type { Infer } from 'spacetimedb'; const HOST = process.env.SPACETIMEDB_HOST ?? 'wss://maincloud.spacetimedb.com'; @@ -38,7 +38,7 @@ export async function fetchPeople(): Promise { conn.disconnect(); reject(error); }) - .subscribe('SELECT * FROM person'); + .subscribe(tables.person); }) .onConnectError((_ctx, error) => { clearTimeout(timeoutId);