diff --git a/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md b/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md index 529e79d6ec4..004f2606bf1 100644 --- a/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md +++ b/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md @@ -441,9 +441,15 @@ spacetimedb.view('my_player', {}, t.option(player.rowType), ctx => { return ctx.db.player.identity.find(ctx.sender); }); -// Return multiple rows +// Return potentially multiple rows spacetimedb.view('top_players', {}, t.array(player.rowType), ctx => { - return ctx.db.player.iter().filter(p => p.score > 1000); + return ctx.db.player.score.filter(1000); +}); + +// Perform a generic filter using the query builder. +// Equivalent to `SELECT * FROM player WHERE score < 1000`. +spacetimedb.view('bottom_players', {}, t.array(player.rowType), ctx => { + return ctx.from.player.where(p => p.score.lt(1000)).build() }); ``` @@ -460,11 +466,19 @@ public static Player? MyPlayer(ViewContext ctx) return ctx.Db.Player.Identity.Find(ctx.Sender); } -// Return multiple rows +// Return potentially multiple rows [SpacetimeDB.View(Public = true)] public static IEnumerable TopPlayers(ViewContext ctx) { - return ctx.Db.Player.Iter().Where(p => p.Score > 1000); + return ctx.Db.Player.Score.Filter(1000); +} + +// Perform a generic filter using the query builder. +// Equivalent to `SELECT * FROM player WHERE score < 1000`. +[SpacetimeDB.View(Public = true)] +public static IEnumerable BottomPlayers(ViewContext ctx) +{ + return ctx.From.Player.Where(p => p.Score.Lt(1000)).Build(); } ``` @@ -472,7 +486,7 @@ public static IEnumerable TopPlayers(ViewContext ctx) ```rust -use spacetimedb::{view, ViewContext}; +use spacetimedb::{view, Query, ViewContext}; // Return single row #[view(name = my_player, public)] @@ -480,12 +494,17 @@ fn my_player(ctx: &ViewContext) -> Option { ctx.db.player().identity().find(ctx.sender) } -// Return multiple rows +// Return potentially multiple rows #[view(name = top_players, public)] fn top_players(ctx: &ViewContext) -> Vec { - ctx.db.player().iter() - .filter(|p| p.score > 1000) - .collect() + ctx.db.player().score().filter(1000).collect() +} + +// Perform a generic filter using the query builder. +// Equivalent to `SELECT * FROM player WHERE score < 1000`. +#[view(name = bottom_players, public)] +fn bottom_players(ctx: &ViewContext) -> Query { + ctx.from.player().r#where(|p| p.score.lt(1000)).build() } ``` 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 97ec7e2077f..430866ce205 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00500-views.md +++ b/docs/docs/00200-core-concepts/00200-functions/00500-views.md @@ -584,6 +584,8 @@ You may notice that views can only access table data through indexed lookups (`. **Why SQL subscriptions can scan.** You might wonder why SQL subscription queries can include full table scans while view functions cannot. The difference is that SQL queries are not black boxes - SpacetimeDB can analyze and transform them. The query engine uses **incremental evaluation**: when rows change, it computes exactly which output rows are affected without re-running the entire query. Think of it like taking the derivative of the query - given a small change in input, compute the small change in output. Since view functions are opaque code, this kind of incremental computation isn't possible. +**Why query builder subscriptions can scan.** For the same reason that SQL subscriptions can scan. The query builder API maps one to one to the SQL API. + **The tradeoff is acceptable for indexed access.** For point lookups (`.find()`) and small range scans (`.filter()` on indexed columns), the performance difference between full re-evaluation and incremental evaluation is small. This is why views are limited to indexed access - it's the subset of operations where the black-box limitation doesn't hurt performance. If you need to aggregate or sort entire tables, consider returning a `Query` from your view instead. Since queries can be analyzed by the query engine, they support incremental evaluation even when scanning full tables. Alternatively, design your schema so the data you need is accessible through indexes. diff --git a/docs/docs/00200-core-concepts/00400-subscriptions.md b/docs/docs/00200-core-concepts/00400-subscriptions.md index b9c4c11785b..ef7eaea4d0a 100644 --- a/docs/docs/00200-core-concepts/00400-subscriptions.md +++ b/docs/docs/00200-core-concepts/00400-subscriptions.md @@ -168,9 +168,9 @@ interface SubscriptionBuilder { // or later during the subscription's lifetime if the module's interface changes. onError(callback: (ctx: ErrorContext, error: Error) => void): SubscriptionBuilder; - // Subscribe to the following SQL queries. + // Subscribe to the following SQL or typed queries. // Returns immediately; callbacks are invoked when data arrives from the server. - subscribe(querySqls: string[]): SubscriptionHandle; + subscribe(query_sql:string | RowTypedQuery | Array>): SubscriptionHandle; // Subscribe to all rows from all tables. // Intended for applications where memory and bandwidth are not concerns. @@ -227,6 +227,31 @@ public sealed class SubscriptionBuilder /// 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(); } ``` @@ -261,6 +286,17 @@ impl SubscriptionBuilder { /// 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) -> 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) -> 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. 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 index 78e583a3c24..b81bb1f437c 100644 --- 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 @@ -24,7 +24,7 @@ Subscriptions replicate a subset of the database to your client, maintaining a l ### Creating Subscriptions -Subscribe to tables or queries using SQL: +Subscribe to tables or queries using raw SQL: @@ -110,6 +110,63 @@ void OnSubscriptionError(const FErrorContext& Ctx) +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.build()]); +``` + + + + +```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().Build()) + .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().build()) + .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 diff --git a/docs/static/ai-rules/spacetimedb-typescript.mdc b/docs/static/ai-rules/spacetimedb-typescript.mdc index a218ddc844c..fe5208f9e37 100644 --- a/docs/static/ai-rules/spacetimedb-typescript.mdc +++ b/docs/static/ai-rules/spacetimedb-typescript.mdc @@ -307,7 +307,9 @@ if (scheduleAt.tag === 'Time') { ### Private table + view pattern -⚠️ **CRITICAL: Views can ONLY access data via index lookups, NOT `.iter()`** +⚠️ **CRITICAL:** Procedural views (views that compute results in code) can ONLY access data via index lookups, NOT `.iter()`. + +If you need a view that scans/filters across many rows (including the entire table), return a **query** built with the query builder (`ctx.from...`). ```typescript // Private table with index on ownerId @@ -337,6 +339,21 @@ spacetimedb.view( ); ``` +### Query builder view pattern (can scan) + +```typescript +// Query-builder views return a query; the SQL engine maintains the result incrementally. +// This can scan the whole table if needed (e.g. leaderboard-style queries). +spacetimedb.anonymousView( + { name: 'top_players', public: true }, + t.array(Player.rowType), + (ctx) => + ctx.from.player + .where(p => p.score.gt(1000)) + .build() +); +``` + ### ViewContext vs AnonymousViewContext ```typescript // ViewContext — has ctx.sender, result varies per user (computed per-subscriber)