Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});
```

Expand All @@ -460,32 +466,45 @@ 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<Player> 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<Player> BottomPlayers(ViewContext ctx)
{
return ctx.From.Player.Where(p => p.Score.Lt(1000)).Build();
}
```

</TabItem>
<TabItem value="rust" label="Rust">

```rust
use spacetimedb::{view, ViewContext};
use spacetimedb::{view, Query, ViewContext};

// Return single row
#[view(name = my_player, public)]
fn my_player(ctx: &ViewContext) -> Option<Player> {
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<Player> {
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<Player> {
ctx.from.player().r#where(|p| p.score.lt(1000)).build()
}
```

Expand Down
2 changes: 2 additions & 0 deletions docs/docs/00200-core-concepts/00200-functions/00500-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 38 additions & 2 deletions docs/docs/00200-core-concepts/00400-subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, any> | Array<string | RowTypedQuery<any, any>>): SubscriptionHandle;

// Subscribe to all rows from all tables.
// Intended for applications where memory and bandwidth are not concerns.
Expand Down Expand Up @@ -227,6 +227,31 @@ public sealed class SubscriptionBuilder
/// in order to replicate only the subset of data which the client needs to function.
/// </summary>
public void SubscribeToAllTables();

/// <summary>
/// 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).
/// </summary>
public TypedSubscriptionBuilder AddQuery<TRow>(
Func<QueryBuilder, Query<TRow>> build
);
}

public sealed class TypedSubscriptionBuilder
{
/// <summary>
/// Add a typed query to this subscription.
/// </summary>
public TypedSubscriptionBuilder AddQuery<TRow>(
Func<QueryBuilder, Query<TRow>> build
);

/// <summary>
/// Subscribe to all typed queries that have been added to this subscription.
/// </summary>
public SubscriptionHandle Subscribe();
}
```

Expand Down Expand Up @@ -261,6 +286,17 @@ impl<M: SpacetimeModule> SubscriptionBuilder<M> {
/// 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<T>(self, build: impl Fn(M::QueryBuilder) -> Query<T>) -> TypedSubscriptionBuilder<M>;
}

impl<M: SpacetimeModule> TypedSubscriptionBuilder<M> {
/// Build a query and invoke `subscribe` in order to subscribe to its results.
pub fn add_query<T>(mut self, build: impl Fn(M::QueryBuilder) -> Query<T>) -> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<Tabs groupId="client-language" queryString>
<TabItem value="typescript" label="TypeScript">
Expand Down Expand Up @@ -110,6 +110,63 @@ void OnSubscriptionError(const FErrorContext& Ctx)
</TabItem>
</Tabs>

Or use the query builder:

<Tabs groupId="client-language" queryString>
<TabItem value="typescript" label="TypeScript">

```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()]);
```

</TabItem>
<TabItem value="csharp" label="C#">

```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();
```

</TabItem>
<TabItem value="rust" label="Rust">

```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();
```

</TabItem>
</Tabs>

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
Expand Down
19 changes: 18 additions & 1 deletion docs/static/ai-rules/spacetimedb-typescript.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading