SpacetimeDB C# Module Library
SpacetimeDB allows using the C# language to write server-side applications called modules. Modules, which run inside a relational database, have direct access to database tables, and expose public functions called reducers that can be invoked over the network. Clients connect directly to the database to read data.
Client Application SpacetimeDB
┌───────────────────────┐ ┌───────────────────────┐
│ │ │ │
│ ┌─────────────────┐ │ SQL Query │ ┌─────────────────┐ │
│ │ Subscribed Data │<─────────────────────│ Database │ │
│ └─────────────────┘ │ │ └─────────────────┘ │
│ │ │ │ ^ │
│ │ │ │ │ │
│ v │ │ v │
│ +─────────────────┐ │ call_reducer() │ ┌─────────────────┐ │
│ │ Client Code │─────────────────────>│ Module Code │ │
│ └─────────────────┘ │ │ └─────────────────┘ │
│ │ │ │
└───────────────────────┘ └───────────────────────┘
C# modules are written with the the C# Module Library (this package). They are built using the dotnet CLI tool and deployed using the spacetime
CLI tool. C# modules can import any NuGet package that supports being compiled to WebAssembly.
(Note: C# can also be used to write clients of SpacetimeDB databases, but this requires using a different library, the SpacetimeDB C# Client SDK. See the documentation on clients for more information.)
This reference assumes you are familiar with the basics of C#. If you aren't, check out the C# language documentation. For a guided introduction to C# Modules, see the C# Module Quickstart.
Overview
SpacetimeDB modules have two ways to interact with the outside world: tables and reducers.
Tables store data and optionally make it readable by clients.
Reducers are functions that modify data and can be invoked by clients over the network. They can read and write data in tables, and write to a private debug log.
These are the only ways for a SpacetimeDB module to interact with the outside world. Calling functions from System.IO
or System.Net
inside a reducer will result in runtime errors.
Declaring tables and reducers is straightforward:
static partial class Module
{
[SpacetimeDB.Table(Name = "player")]
public partial struct Player
{
public int Id;
public string Name;
}
[SpacetimeDB.Reducer]
public static void AddPerson(ReducerContext ctx, int Id, string Name) {
ctx.Db.player.Insert(new Player { Id = Id, Name = Name });
}
}
Note that reducers don't return data directly; they can only modify the database. Clients connect directly to the database and use SQL to query public tables. Clients can also subscribe to a set of rows using SQL queries and receive streaming updates whenever any of those rows change.
Tables and reducers in C# modules can use any type annotated with [SpacetimeDB.Type]
.
Setup
To create a C# module, install the spacetime
CLI tool in your preferred shell. Navigate to your work directory and run the following command:
spacetime init --lang csharp my-project-directory
This creates a dotnet
project in my-project-directory
with the following StdbModule.csproj
:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SpacetimeDB.Runtime" Version="1.0.0" />
</ItemGroup>
</Project>
This is a standard csproj
, with the exception of the line <RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
.
This line is important: it allows the project to be compiled to a WebAssembly module.
The project's Lib.cs
will contain the following skeleton:
public static partial class Module
{
[SpacetimeDB.Table]
public partial struct Person
{
[SpacetimeDB.AutoInc]
[SpacetimeDB.PrimaryKey]
public int Id;
public string Name;
public int Age;
}
[SpacetimeDB.Reducer]
public static void Add(ReducerContext ctx, string name, int age)
{
var person = ctx.Db.Person.Insert(new Person { Name = name, Age = age });
Log.Info($"Inserted {person.Name} under #{person.Id}");
}
[SpacetimeDB.Reducer]
public static void SayHello(ReducerContext ctx)
{
foreach (var person in ctx.Db.Person.Iter())
{
Log.Info($"Hello, {person.Name}!");
}
Log.Info("Hello, World!");
}
}
This skeleton declares a table and some reducers.
You can also add some lifecycle reducers to the Module
class using the following code:
[Reducer(ReducerKind.Init)]
public static void Init(ReducerContext ctx)
{
// Run when the module is first loaded.
}
[Reducer(ReducerKind.ClientConnected)]
public static void ClientConnected(ReducerContext ctx)
{
// Called when a client connects.
}
[Reducer(ReducerKind.ClientDisconnected)]
public static void ClientDisconnected(ReducerContext ctx)
{
// Called when a client connects.
}
To compile the project, run the following command:
spacetime build
SpacetimeDB requires a WebAssembly-compatible dotnet
toolchain. If the spacetime
cli finds a compatible version of dotnet
that it can run, it will automatically install the wasi-experimental
workload and use it to build your application. This can also be done manually using the command:
dotnet workload install wasi-experimental
If you are managing your dotnet installation in some other way, you will need to install the wasi-experimental
workload yourself.
To build your application and upload it to the public SpacetimeDB network, run:
spacetime login
And then:
spacetime publish [MY_DATABASE_NAME]
For example:
spacetime publish silly_demo_app
When you publish your module, a database named silly_demo_app
will be created with the requested tables, and the module will be installed inside it.
The output of spacetime publish
will end with a line:
Created new database with name: <name>, identity: <hex string>
This name is the human-readable name of the created database, and the hex string is its Identity
. These distinguish the created database from the other databases running on the SpacetimeDB network. They are used when administering the application, for example using the spacetime logs <DATABASE_NAME>
command. You should probably write the database name down in a text file so that you can remember it.
After modifying your project, you can run:
spacetime publish <DATABASE_NAME>
to update the module attached to your database. Note that SpacetimeDB tries to automatically migrate your database schema whenever you run spacetime publish
.
You can also generate code for clients of your module using the spacetime generate
command. See the client SDK documentation for more information.
How it works
Under the hood, SpacetimeDB modules are WebAssembly modules that import a specific WebAssembly ABI and export a small number of special functions. This is automatically configured when you add the SpacetimeDB.Runtime
package as a dependency of your application.
The SpacetimeDB host is an application that hosts SpacetimeDB databases. Its source code is available under the Business Source License with an Additional Use Grant. You can run your own host, or you can upload your module to the public SpacetimeDB network. The network will create a database for you and install your module in it to serve client requests.
In More Detail: Publishing a Module
The spacetime publish [DATABASE_IDENTITY]
command compiles a module and uploads it to a SpacetimeDB host. After this:
- The host finds the database with the requested
DATABASE_IDENTITY
.- (Or creates a fresh database and identity, if no identity was provided).
- The host loads the new module and inspects its requested database schema. If there are changes to the schema, the host tries perform an automatic migration. If the migration fails, publishing fails.
- The host terminates the old module attached to the database.
- The host installs the new module into the database. It begins running the module's lifecycle reducers and scheduled reducers, starting with the
Init
reducer. - The host begins allowing clients to call the module's reducers.
From the perspective of clients, this process is seamless. Open connections are maintained and subscriptions continue functioning. Automatic migrations forbid most table changes except for adding new tables, so client code does not need to be recompiled. However:
- Clients may witness a brief interruption in the execution of scheduled reducers (for example, game loops.)
- New versions of a module may remove or change reducers that were previously present. Client code calling those reducers will receive runtime errors.
Tables
Tables are declared using the [SpacetimeDB.Table]
attribute.
This macro is applied to a C# partial class
or partial struct
with named fields. (The partial
modifier is required to allow code generation to add methods.) All of the fields of the table must be marked with [SpacetimeDB.Type]
.
The resulting type is used to store rows of the table. It's a normal class (or struct). Row values are not special -- operations on row types do not, by themselves, modify the table. Instead, a ReducerContext
is needed to get a handle to the table.
public static partial class Module {
/// <summary>
/// A Person is a row of the table person.
/// </summary>
[SpacetimeDB.Table(Name = "person", Public)]
public partial struct Person {
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
ulong Id;
[SpacetimeDB.Index.BTree]
string Name;
}
// `Person` is a normal C# struct type.
// Operations on a `Person` do not, by themselves, do anything.
// The following function does not interact with the database at all.
public static void DoNothing() {
// Creating a `Person` DOES NOT modify the database.
var person = new Person { Id = 0, Name = "Joe Average" };
// Updating a `Person` DOES NOT modify the database.
person.Name = "Joanna Average";
// Deallocating a `Person` DOES NOT modify the database.
person = null;
}
// To interact with the database, you need a `ReducerContext`,
// which is provided as the first parameter of any reducer.
[SpacetimeDB.Reducer]
public static void DoSomething(ReducerContext ctx) {
// The following inserts a row into the table:
var examplePerson = ctx.Db.person.Insert(new Person { id = 0, name = "Joe Average" });
// `examplePerson` is a COPY of the row stored in the database.
// If we update it:
examplePerson.name = "Joanna Average".to_string();
// Our copy is now updated, but the database's copy is UNCHANGED.
// To push our change through, we can call `UniqueIndex.Update()`:
examplePerson = ctx.Db.person.Id.Update(examplePerson);
// Now the database and our copy are in sync again.
// We can also delete the row in the database using `UniqueIndex.Delete()`.
ctx.Db.person.Id.Delete(examplePerson.Id);
}
}
(See reducers for more information on declaring reducers.)
This library generates a custom API for each table, depending on the table's name and structure.
All tables support getting a handle implementing the ITableView
interface from a ReducerContext
, using:
ctx.Db.{table_name}
For example,
ctx.Db.person
Unique and primary key columns and indexes generate additional accessors, such as ctx.Db.person.Id
and ctx.Db.person.Name
.
Interface `ITableView`
namespace SpacetimeDB.Internal;
public interface ITableView<View, Row>
where Row : IStructuralReadWrite, new()
{
/* ... */
}
Implemented for every table handle generated by the Table
attribute.
For a table named {name}
, a handle can be extracted from a ReducerContext
using ctx.Db.{name}
. For example, ctx.Db.person
.
Contains methods that are present for every table handle, regardless of what unique constraints and indexes are present.
The type Row
is the type of rows in the table.
Name | Description |
---|---|
Method Insert |
Insert a row into the table |
Method Delete |
Delete a row from the table |
Method Iter |
Iterate all rows of the table |
Property Count |
Count all rows of the table |
Method `ITableView.Insert`
Row Insert(Row row);
Inserts row
into the table.
The return value is the inserted row, with any auto-incrementing columns replaced with computed values.
The insert
method always returns the inserted row, even when the table contains no auto-incrementing columns.
(The returned row is a copy of the row in the database.
Modifying this copy does not directly modify the database.
See UniqueIndex.Update()
if you want to update the row.)
Throws an exception if inserting the row violates any constraints.
Inserting a duplicate row in a table is a no-op, as SpacetimeDB is a set-semantic database.
Method `ITableView.Delete`
bool Delete(Row row);
Deletes a row equal to row
from the table.
Returns true
if the row was present and has been deleted,
or false
if the row was not present and therefore the tables have not changed.
Unlike Insert
, there is no need to return the deleted row,
as it must necessarily have been exactly equal to the row
argument.
No analogue to auto-increment placeholders exists for deletions.
Throws an exception if deleting the row would violate any constraints.
Method `ITableView.Iter`
IEnumerable<Row> Iter();
Iterate over all rows of the table.
(This keeps track of changes made to the table since the start of this reducer invocation. For example, if rows have been deleted since the start of this reducer invocation, those rows will not be returned by Iter
. Similarly, inserted rows WILL be returned.)
For large tables, this can be a slow operation! Prefer filtering by an Index
or finding a UniqueIndex
if possible.
Property `ITableView.Count`
ulong Count { get; }
Returns the number of rows of this table.
This takes into account modifications by the current transaction, even though those modifications have not yet been committed or broadcast to clients. This applies generally to insertions, deletions, updates, and iteration as well.
Public and Private Tables
By default, tables are considered private. This means that they are only readable by the database owner and by reducers. Reducers run inside the database, so clients cannot see private tables at all or even know of their existence.
Using the [SpacetimeDB.Table(Name = "table_name", Public)]
flag makes a table public. Public tables are readable by all clients. They can still only be modified by reducers.
(Note that, when run by the module owner, the spacetime sql <SQL_QUERY>
command can also read private tables. This is for debugging convenience. Only the module owner can see these tables. This is determined by the Identity
stored by the spacetime login
command. Run spacetime login show
to print your current logged-in Identity
.)
To learn how to subscribe to a public table, see the client SDK documentation.
Unique and Primary Key Columns
Columns of a table (that is, fields of a [Table]
struct) can be annotated with [Unique]
or [PrimaryKey]
. Multiple columns can be [Unique]
, but only one can be [PrimaryKey]
. For example:
[SpacetimeDB.Table(Name = "citizen")]
public partial struct Citizen {
[SpacetimeDB.PrimaryKey]
ulong Id;
[SpacetimeDB.Unique]
string Ssn;
[SpacetimeDB.Unique]
string Email;
string name;
}
Every row in the table Person
must have unique entries in the id
, ssn
, and email
columns. Attempting to insert multiple Person
s with the same id
, ssn
, or email
will throw an exception.
Any [Unique]
or [PrimaryKey]
column supports getting a UniqueIndex
from a ReducerContext
using:
ctx.Db.{table}.{unique_column}
For example,
ctx.Db.citizen.Ssn
Notice that updating a row is only possible if a row has a unique column -- there is no update
method in the base ITableView
interface. SpacetimeDB has no notion of rows having an "identity" aside from their unique / primary keys.
The [PrimaryKey]
annotation implies a [Unique]
annotation, but avails additional methods in the client-side SDKs.
It is not currently possible to mark a group of fields as collectively unique.
Filtering on unique columns is only supported for a limited number of types.
Class `UniqueIndex`
namespace SpacetimeDB.Internal;
public abstract class UniqueIndex<Handle, Row, Column, RW> : IndexBase<Row>
where Handle : ITableView<Handle, Row>
where Row : IStructuralReadWrite, new()
where Column : IEquatable<Column>
{
/* ... */
}
A unique index on a column. Available for [Unique]
and [PrimaryKey]
columns.
(A custom class derived from UniqueIndex
is generated for every such column.)
Row
is the type decorated with [SpacetimeDB.Table]
, Column
is the type of the column,
and Handle
is the type of the generated table handle.
For a table table with a column column, use ctx.Db.{table}.{column}
to get a UniqueColumn
from a ReducerContext
.
Example:
using SpacetimeDB;
public static partial class Module {
[Table(Name = "user")]
public partial struct User {
[PrimaryKey]
uint Id;
[Unique]
string Username;
ulong DogCount;
}
[Reducer]
void Demo(ReducerContext ctx) {
var idIndex = ctx.Db.user.Id;
var exampleUser = idIndex.find(357).unwrap();
exampleUser.dog_count += 5;
idIndex.update(exampleUser);
var usernameIndex = ctx.Db.user.Username;
usernameIndex.delete("Evil Bob");
}
}
Name | Description |
---|---|
Method Find |
Find a row by the value of a unique column |
Method Update |
Update a row with a unique column |
Method Delete |
Delete a row by the value of a unique column |
Method `UniqueIndex.Find`
Row? Find(Column key);
Finds and returns the row where the value in the unique column matches the supplied key
,
or null
if no such row is present in the database state.
Method `UniqueIndex.Update`
Row Update(Row row);
Deletes the row where the value in the unique column matches that in the corresponding field of row
and then inserts row
.
Returns the new row as actually inserted, with any auto-inc placeholders substituted for computed values.
Throws if no row was previously present with the matching value in the unique column, or if either the delete or the insertion would violate a constraint.
Method `UniqueIndex.Delete`
bool Delete(Column key);
Deletes the row where the value in the unique column matches the supplied key
, if any such row is present in the database state.
Returns true
if a row with the specified key
was previously present and has been deleted,
or false
if no such row was present.
Auto-inc columns
Columns can be marked [SpacetimeDB.AutoInc]
. This can only be used on integer types (int
, ulong
, etc.)
When inserting into or updating a row in a table with an [AutoInc]
column, if the annotated column is set to zero (0
), the database will automatically overwrite that zero with an atomically increasing value.
[ITableView.Insert
] and UniqueIndex.Update()
returns rows with [AutoInc]
columns set to the values that were actually written into the database.
public static partial class Module
{
[SpacetimeDB.Table(Name = "example")]
public partial struct Example
{
[SpacetimeDB.AutoInc]
public int Field;
}
[SpacetimeDB.Reducer]
public static void InsertAutoIncExample(ReducerContext ctx, int Id, string Name) {
for (var i = 0; i < 10; i++) {
// These will have distinct, unique values
// at rest in the database, since they
// are inserted with the sentinel value 0.
var actual = ctx.Db.example.Insert(new Example { Field = 0 });
Debug.Assert(actual.Field != 0);
}
}
}
[AutoInc]
is often combined with [Unique]
or [PrimaryKey]
to automatically assign unique integer identifiers to rows.
Indexes
SpacetimeDB supports both single- and multi-column B-Tree indexes.
Indexes are declared using the syntax:
[SpacetimeDB.Index.BTree(Name = "IndexName", Columns = [nameof(Column1), nameof(Column2), nameof(Column3)])]
For example:
[SpacetimeDB.Table(Name = "paper")]
[SpacetimeDB.Index.BTree(Name = "TitleAndDate", Columns = [nameof(Title), nameof(Date)])]
[SpacetimeDB.Index.BTree(Name = "UrlAndCountry", Columns = [nameof(Url), nameof(Country)])]
public partial struct AcademicPaper {
public string Title;
public string Url;
public string Date;
public string Venue;
public string Country;
}
Multiple indexes can be declared.
Single-column indexes can also be declared using an annotation on a column:
[SpacetimeDB.Table(Name = "academic_paper")]
public partial struct AcademicPaper {
public string Title;
public string Url;
[SpacetimeDB.Index.BTree] // The index will be named "Date".
public string Date;
[SpacetimeDB.Index.BTree] // The index will be named "Venue".
public string Venue;
[SpacetimeDB.Index.BTree(Name = "ByCountry")] // The index will be named "ByCountry".
public string Country;
}
Any table supports getting an Index
using ctx.Db.{table}.{index}
. For example, ctx.Db.academic_paper.TitleAndDate
or ctx.Db.academic_paper.Venue
.
Class `Index`
public abstract class IndexBase<Row>
where Row : IStructuralReadWrite, new()
{
// ...
}
Each index generates a subclass of IndexBase
, which is accessible via ctx.Db.{table}.{index}
. For example, ctx.Db.academic_paper.TitleAndDate
.
Indexes can be applied to a variable number of columns, referred to as Column1
, Column2
, Column3
... in the following examples.
Name | Description |
---|---|
Method Filter |
Filter rows in an index |
Method Delete |
Delete rows in an index |
Method `Index.Filter`
public IEnumerable<Row> Filter(Column1 bound);
public IEnumerable<Row> Filter(Bound<Column1> bound);
public IEnumerable<Row> Filter((Column1, Column2) bound);
public IEnumerable<Row> Filter((Column1, Bound<Column2>) bound);
public IEnumerable<Row> Filter((Column1, Column2, Column3) bound);
public IEnumerable<Row> Filter((Column1, Column2, Bound<Column3>) bound);
// ...
Returns an iterator over all rows in the database state where the indexed column(s) match the passed bound
. Bound is a tuple of column values, possibly terminated by a Bound<LastColumn>
. A Bound<LastColumn>
is simply a tuple (LastColumn Min, LastColumn Max)
. Any prefix of the indexed columns can be passed, for example:
using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Table(Name = "zoo_animal")]
[SpacetimeDB.Index.BTree(Name = "SpeciesAgeName", Columns = [nameof(Species), nameof(Age), nameof(Name)])]
public partial struct ZooAnimal
{
public string Species;
public uint Age;
public string Name;
[SpacetimeDB.PrimaryKey]
public uint Id;
}
[SpacetimeDB.Reducer]
public static void Example(ReducerContext ctx)
{
foreach (var baboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter("baboon"))
{
// Work with the baboon.
}
foreach (var animal in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("b", "e")))
{
// Work with the animal.
// The name of the species starts with a character between "b" and "e".
}
foreach (var babyBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1)))
{
// Work with the baby baboon.
}
foreach (var youngBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", (1, 5))))
{
// Work with the young baboon.
}
foreach (var babyBaboonNamedBob in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1, "Bob")))
{
// Work with the baby baboon named "Bob".
}
foreach (var babyBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1, ("a", "f"))))
{
// Work with the baby baboon, whose name starts with a letter between "a" and "f".
}
}
}
Method `Index.Delete`
public ulong Delete(Column1 bound);
public ulong Delete(Bound<Column1> bound);
public ulong Delete((Column1, Column2) bound);
public ulong Delete((Column1, Bound<Column2>) bound);
public ulong Delete((Column1, Column2, Column3) bound);
public ulong Delete((Column1, Column2, Bound<Column3>) bound);
// ...
Delete all rows in the database state where the indexed column(s) match the passed bound
. Returns the count of rows deleted. Note that there may be multiple rows deleted even if only a single column value is passed, since the index is not guaranteed to be unique.
Reducers
Reducers are declared using the [SpacetimeDB.Reducer]
attribute.
[SpacetimeDB.Reducer]
is always applied to static C# functions. The first parameter of a reducer must be a [ReducerContext
]. The remaining parameters must be types marked with [SpacetimeDB.Type
]. Reducers should return void
.
public static partial class Module {
[SpacetimeDB.Reducer]
public static void GivePlayerItem(
ReducerContext context,
ulong PlayerId,
ulong ItemId
)
{
// ...
}
}
Every reducer runs inside a database transaction. This means that reducers will not observe the effects of other reducers modifying the database while they run. If a reducer fails, all of its changes to the database will automatically be rolled back. Reducers can fail by throwing an exception.
Class `ReducerContext`
public sealed record ReducerContext : DbContext<Local>, Internal.IReducerContext
{
// ...
}
Reducers have access to a special [ReducerContext
] parameter. This parameter allows reading and writing the database attached to a module. It also provides some additional functionality, like generating random numbers and scheduling future operations.
[ReducerContext
] provides access to the database tables via the .Db
property. The [Table]
attribute generated code that adds table accessors to this property.
Name | Description |
---|---|
Property Db |
The current state of the database |
Property Sender |
The Identity of the caller of the reducer |
Property ConnectionId |
The ConnectionId of the caller of the reducer, if any |
Property Rng |
A System.Random instance. |
Property Timestamp |
The Timestamp of the reducer invocation |
Property Identity |
The Identity of the module |
Property `ReducerContext.Db`
DbView Db;
Allows accessing the local database attached to a module.
The [Table]
attribute generates a field of this property.
For a table named table, use ctx.Db.{table}
to get a table view.
For example, ctx.Db.users
.
You can also use ctx.Db.{table}.{index}
to get an index or unique index.
Property `ReducerContext.Sender`
Identity Sender;
The Identity
of the client that invoked the reducer.
Property `ReducerContext.ConnectionId`
ConnectionId? ConnectionId;
The ConnectionId
of the client that invoked the reducer.
null
if no ConnectionId
was supplied to the /database/call
HTTP endpoint,
or via the CLI's spacetime call
subcommand.
Property `ReducerContext.Rng`
Random Rng;
A System.Random
that can be used to generate random numbers.
Property `ReducerContext.Timestamp`
Timestamp Timestamp;
The time at which the reducer was invoked.
Property `ReducerContext.Identity`
Identity Identity;
The Identity
of the module.
This can be used to check whether a scheduled reducer is being called by a user.
Note: this is not the identity of the caller, that's ReducerContext.Sender
.
Lifecycle Reducers
A small group of reducers are called at set points in the module lifecycle. These are used to initialize the database and respond to client connections. You can have one of each per module.
These reducers cannot be called manually and may not have any parameters except for ReducerContext
.
The `Init` reducer
This reducer is marked with [SpacetimeDB.Reducer(ReducerKind.Init)]
. It is run the first time a module is published and any time the database is cleared.
If an error occurs when initializing, the module will not be published.
This reducer can be used to configure any static data tables used by your module. It can also be used to start running scheduled reducers.
The `ClientConnected` reducer
This reducer is marked with [SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
. It is run when a client connects to the SpacetimeDB module. Their identity can be found in the sender value of the ReducerContext
.
If an error occurs in the reducer, the client will be disconnected.
The `ClientDisconnected` reducer
This reducer is marked with [SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]
. It is run when a client disconnects from the SpacetimeDB module. Their identity can be found in the sender value of the ReducerContext
.
If an error occurs in the disconnect reducer, the client is still recorded as disconnected.
Scheduled Reducers
Reducers can schedule other reducers to run asynchronously. This allows calling the reducers at a particular time, or at repeating intervals. This can be used to implement timers, game loops, and maintenance tasks.
The scheduling information for a reducer is stored in a table. This table has two mandatory fields:
- An
[AutoInc] [PrimaryKey] ulong
field that identifies scheduled reducer calls. - A
ScheduleAt
field that says when to call the reducer.
Managing timers with a scheduled table is as simple as inserting or deleting rows from the table. This makes scheduling transactional in SpacetimeDB. If a reducer A first schedules B but then errors for some other reason, B will not be scheduled to run.
A ScheduleAt
can be created from a Timestamp
, in which case the reducer will be scheduled once, or from a TimeDuration
, in which case the reducer will be scheduled in a loop.
Example:
using SpacetimeDB;
public static partial class Module
{
// First, we declare the table with scheduling information.
[Table(Name = "send_message_schedule", Scheduled = nameof(SendMessage), ScheduledAt = nameof(ScheduledAt))]
public partial struct SendMessageSchedule
{
// Mandatory fields:
[PrimaryKey]
[AutoInc]
public ulong Id;
public ScheduleAt ScheduledAt;
// Custom fields:
public string Message;
}
// Then, we declare the scheduled reducer.
// The first argument of the reducer should be, as always, a `ReducerContext`.
// The second argument should be a row of the scheduling information table.
[Reducer]
public static void SendMessage(ReducerContext ctx, SendMessageSchedule schedule)
{
Log.Info($"Sending message {schedule.Message}");
// ...
}
// Finally, we want to actually start scheduling reducers.
// It's convenient to do this inside the `init` reducer.
[Reducer(ReducerKind.Init)]
public static void Init(ReducerContext ctx)
{
var currentTime = ctx.Timestamp;
var tenSeconds = new TimeDuration { Microseconds = +10_000_000 };
var futureTimestamp = currentTime + tenSeconds;
ctx.Db.send_message_schedule.Insert(new()
{
Id = 0, // Have [AutoInc] assign an Id.
ScheduledAt = new ScheduleAt.Time(futureTimestamp),
Message = "I'm a bot sending a message one time!"
});
ctx.Db.send_message_schedule.Insert(new()
{
Id = 0, // Have [AutoInc] assign an Id.
ScheduledAt = new ScheduleAt.Interval(tenSeconds),
Message = "I'm a bot sending a message every ten seconds!"
});
}
}
Scheduled reducers are called on a best-effort basis and may be slightly delayed in their execution when a database is under heavy load.
Restricting scheduled reducers
Scheduled reducers are normal reducers, and may still be called by clients.
If a scheduled reducer should only be called by the scheduler, consider beginning it with a check that the caller Identity
is the module:
[Reducer]
public static void SendMessage(ReducerContext ctx, SendMessageSchedule schedule)
{
if (ctx.Sender != ctx.Identity)
{
throw new Exception("Reducer SendMessage may not be invoked by clients, only via scheduling.");
}
// ...
}
Automatic migrations
When you spacetime publish
a module that has already been published using spacetime publish <DATABASE_NAME_OR_IDENTITY>
,
SpacetimeDB attempts to automatically migrate your existing database to the new schema. (The "schema" is just the collection
of tables and reducers you've declared in your code, together with the types they depend on.) This form of migration is limited and only supports a few kinds of changes.
On the plus side, automatic migrations usually don't break clients. The situations that may break clients are documented below.
The following changes are always allowed and never breaking:
- ✅ Adding tables. Non-updated clients will not be able to see the new tables.
- ✅ Adding indexes.
- ✅ Adding or removing
[AutoInc]
annotations. - ✅ Changing tables from private to public.
- ✅ Adding reducers.
- ✅ Removing
[Unique]
annotations.
The following changes are allowed, but may break clients:
- ⚠️ Changing or removing reducers. Clients that attempt to call the old version of a changed reducer will receive runtime errors.
- ⚠️ Changing tables from public to private. Clients that are subscribed to a newly-private table will receive runtime errors.
- ⚠️ Removing
[PrimaryKey]
annotations. Non-updated clients will still use the old[PrimaryKey]
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 some situtations.
The specific problem is subscription queries involving semijoins, such as:
For performance reasons, SpacetimeDB will only allow this kind of subscription query if there are indexes onSELECT Employee.* FROM Employee JOIN Dept ON Employee.DeptName = Dept.DeptName )
Employee.DeptName
andDept.DeptName
. Removing either of these indexes will invalidate this subscription query, resulting in client-side runtime errors.
The following changes are forbidden without a manual migration:
- ❌ Removing tables.
- ❌ Changing the columns of a table. This includes changing the order of columns of a table.
- ❌ Changing whether a table is used for scheduling.
- ❌ Adding
[Unique]
or[PrimaryKey]
constraints. This could result in existing tables being in an invalid state.
Currently, manual migration support is limited. The spacetime publish --clear-database <DATABASE_IDENTITY>
command can be used to COMPLETELY DELETE and reinitialize your database, but naturally it should be used with EXTREME CAUTION.
Other infrastructure
Class `Log`
namespace SpacetimeDB
{
public static class Log
{
public static void Debug(string message);
public static void Error(string message);
public static void Exception(string message);
public static void Exception(Exception exception);
public static void Info(string message);
public static void Trace(string message);
public static void Warn(string message);
}
}
Methods for writing to a private debug log. Log messages will include file and line numbers.
Log outputs of a running module can be inspected using the spacetime logs
command:
spacetime logs <DATABASE_IDENTITY>
These are only visible to the database owner, not to clients or other developers.
Note that Log.Error
and Log.Exception
only write to the log, they do not throw exceptions themselves.
Example:
using SpacetimeDB;
public static partial class Module {
[Table(Name = "user")]
public partial struct User {
[PrimaryKey]
uint Id;
[Unique]
string Username;
ulong DogCount;
}
[Reducer]
public static void LogDogs(ReducerContext ctx) {
Log.Info("Examining users.");
var totalDogCount = 0;
foreach (var user in ctx.Db.user.Iter()) {
Log.Info($" User: Id = {user.Id}, Username = {user.Username}, DogCount = {user.DogCount}");
totalDogCount += user.DogCount;
}
if (totalDogCount < 300) {
Log.Warn("Insufficient dogs.");
}
if (totalDogCount < 100) {
Log.Error("Dog population is critically low!");
}
}
}
Attribute `[SpacetimeDB.Type]`
This attribute makes types self-describing, allowing them to automatically register their structure
with SpacetimeDB. Any C# type annotated with [SpacetimeDB.Type]
can be used as a table column or reducer argument.
Types marked [SpacetimeDB.Table]
are automatically marked [SpacetimeDB.Type]
.
[SpacetimeDB.Type]
can be combined with [SpacetimeDB.TaggedEnum
] to use tagged enums in tables or reducers.
using SpacetimeDB;
public static partial class Module {
[Type]
public partial struct Coord {
public int X;
public int Y;
}
[Type]
public partial struct TankData {
public int Ammo;
public int LeftTreadHealth;
public int RightTreadHealth;
}
[Type]
public partial struct TransportData {
public int TroopCount;
}
// A type that could be either the data for a Tank or the data for a Transport.
// See SpacetimeDB.TaggedEnum docs.
[Type]
public partial record VehicleData : TaggedEnum<(TankData Tank, TransportData Transport)> {}
[Table(Name = "vehicle")]
public partial struct Vehicle {
[PrimaryKey]
[AutoInc]
public uint Id;
public Coord Coord;
public VehicleData Data;
}
[SpacetimeDB.Reducer]
public static void InsertVehicle(ReducerContext ctx, Coord Coord, VehicleData Data) {
ctx.Db.vehicle.Insert(new Vehicle { Id = 0, Coord = Coord, Data = Data });
}
}
The fields of the struct/enum must also be marked with [SpacetimeDB.Type]
.
Some types from the standard library are also considered to be marked with [SpacetimeDB.Type]
, including:
byte
sbyte
ushort
short
uint
int
ulong
long
SpacetimeDB.U128
SpacetimeDB.I128
SpacetimeDB.U256
SpacetimeDB.I256
List<T>
whereT
is a[SpacetimeDB.Type]
Struct `Identity`
namespace SpacetimeDB;
public readonly record struct Identity
{
public static Identity FromHexString(string hex);
public string ToString();
}
An Identity
for something interacting with the database.
This is a record struct, so it can be printed, compared with ==
, and used as a Dictionary
key.
ToString()
returns a hex encoding of the Identity, suitable for printing.
Struct `ConnectionId`
namespace SpacetimeDB;
public readonly record struct ConnectionId
{
public static ConnectionId? FromHexString(string hex);
public string ToString();
}
A unique identifier for a client connection to a SpacetimeDB database.
This is a record struct, so it can be printed, compared with ==
, and used as a Dictionary
key.
ToString()
returns a hex encoding of the ConnectionId
, suitable for printing.
Struct `Timestamp`
namespace SpacetimeDB;
public record struct Timestamp(long MicrosecondsSinceUnixEpoch)
: IStructuralReadWrite,
IComparable<Timestamp>
{
// ...
}
A point in time, measured in microseconds since the Unix epoch.
This can be converted to/from a standard library DateTimeOffset
. It is provided for consistency of behavior between SpacetimeDB's supported module and SDK languages.
Name | Description |
---|---|
Property MicrosecondsSinceUnixEpoch |
Microseconds since the unix epoch. |
Conversion to/from DateTimeOffset |
Convert to/from a standard library DateTimeOffset |
Static property UNIX_EPOCH |
The unix epoch as a Timestamp |
Method TimeDurationSince |
Measure the time elapsed since another Timestamp |
Operator + |
Add a [TimeDuration ] to a Timestamp |
Method CompareTo |
Compare to another Timestamp |
Property `Timestamp.MicrosecondsSinceUnixEpoch`
long MicrosecondsSinceUnixEpoch;
The number of microseconds since the unix epoch.
A positive value means a time after the Unix epoch, and a negative value means a time before.
Conversion to/from `DateTimeOffset`
public static implicit operator DateTimeOffset(Timestamp t);
public static implicit operator Timestamp(DateTimeOffset offset);
Timestamp
may be converted to/from a DateTimeOffset
, but the conversion can lose precision.
This type has less precision than DateTimeOffset (units of microseconds rather than units of 100ns).
Static property `Timestamp.UNIX_EPOCH`
public static readonly Timestamp UNIX_EPOCH = new Timestamp { MicrosecondsSinceUnixEpoch = 0 };
The unix epoch as a Timestamp
.
Method `Timestamp.TimeDurationSince`
public readonly TimeDuration TimeDurationSince(Timestamp earlier) =>
Create a new [TimeDuration
] that is the difference between two Timestamps
.
Operator `Timestamp.+`
public static Timestamp operator +(Timestamp point, TimeDuration interval);
Create a new Timestamp
that occurs interval
after point
.
Method `Timestamp.CompareTo`
public int CompareTo(Timestamp that)
Compare two Timestamp
s.
Struct `TimeDuration`
namespace SpacetimeDB;
public record struct TimeDuration(long Microseconds) : IStructuralReadWrite {
// ...
}
A duration that represents an interval between two [Timestamp
]s.
This type may be converted to/from a TimeSpan
. It is provided for consistency of behavior between SpacetimeDB's supported module and SDK languages.
Name | Description |
---|---|
Property Microseconds |
Microseconds between the [Timestamp ]s. |
Conversion to/from TimeSpan |
Convert to/from a standard library TimeSpan |
Static property ZERO |
The duration between any [Timestamp ] and itself |
Property `TimeDuration.Microseconds`
long Microseconds;
The number of microseconds between two [Timestamp
]s.
Conversion to/from `TimeSpan`
public static implicit operator TimeSpan(TimeDuration d) =>
new(d.Microseconds * Util.TicksPerMicrosecond);
public static implicit operator TimeDuration(TimeSpan timeSpan) =>
new(timeSpan.Ticks / Util.TicksPerMicrosecond);
TimeDuration
may be converted to/from a TimeSpan
, but the conversion can lose precision.
This type has less precision than TimeSpan
(units of microseconds rather than units of 100ns).
Static property `TimeDuration.ZERO`
public static readonly TimeDuration ZERO = new TimeDuration { Microseconds = 0 };
The duration between any Timestamp
and itself.
Record `TaggedEnum`
namespace SpacetimeDB;
public abstract record TaggedEnum<Variants> : IEquatable<TaggedEnum<Variants>> where Variants : struct, ITuple
A tagged enum is a type that can hold a value from any one of several types. TaggedEnum
uses code generation to accomplish this.
For example, to declare a type that can be either a string
or an int
, write:
[SpacetimeDB.Type]
public partial record ProductId : SpacetimeDB.TaggedEnum<(string Text, uint Number)> { }
Here there are two variants: one is named Text
and holds a string
, the other is named Number
and holds a uint
.
To create a value of this type, use new {Type}.{Variant}({data})
. For example:
ProductId a = new ProductId.Text("apple");
ProductId b = new ProductId.Number(57);
ProductId c = new ProductId.Number(59);
To use a value of this type, you need to check which variant it stores. This is done with C# pattern matching syntax. For example:
public static void Print(ProductId id)
{
if (id is ProductId.Text(var s))
{
Log.Info($"Textual product ID: '{s}'");
}
else if (id is ProductId.Number(var i))
{
Log.Info($"Numeric Product ID: {i}");
}
}
A TaggedEnum
can have up to 255 variants, and the variants can be any type marked with [[SpacetimeDB.Type]
].
[SpacetimeDB.Type]
public partial record ManyChoices : SpacetimeDB.TaggedEnum<(
string String,
int Int,
List<int> IntList,
Banana Banana,
List<List<Banana>> BananaMatrix
)> { }
[SpacetimeDB.Type]
public partial struct Banana {
public int Sweetness;
public int Rot;
}
TaggedEnums
are an excellent alternative to nullable fields when groups of fields are always set together. Consider a data type like:
[SpacetimeDB.Type]
public partial struct ShapeData {
public int? CircleRadius;
public int? RectWidth;
public int? RectHeight;
}
Often this is supposed to be a circle XOR a rectangle -- that is, not both at the same time. If this is the case, then we don't want to set circleRadius
at the same time as rectWidth
or rectHeight
. Also, if rectWidth
is set, we expect rectHeight
to be set.
However, C# doesn't know about this, so code using this type will be littered with extra null checks.
If we instead write:
[SpacetimeDB.Type]
public partial struct CircleData {
public int Radius;
}
[SpacetimeDB.Type]
public partial struct RectData {
public int Width;
public int Height;
}
[SpacetimeDB.Type]
public partial record ShapeData : SpacetimeDB.TaggedEnum<(CircleData Circle, RectData Rect)> { }
Then code using a ShapeData
will only have to do one check -- do I have a circle or a rectangle?
And in each case, the data will be guaranteed to have exactly the fields needed.
Record `ScheduleAt`
namespace SpacetimeDB;
public partial record ScheduleAt : TaggedEnum<(TimeDuration Interval, Timestamp Time)>
When a scheduled reducer should execute, either at a specific point in time, or at regular intervals for repeating schedules.
Stored in reducer-scheduling tables as a column.