Migrating from SpacetimeDB 1.0 to 2.0
This guide covers the breaking changes between SpacetimeDB 1.0 and 2.0 and how to update your code.
Overview of Changes
SpacetimeDB 2.0 introduces a new WebSocket protocol (v2) and SDK with several breaking changes aimed at simplifying the programming model and improving security:
- Reducer callbacks removed -- replaced by event tables and per-call
_then()callbacks light_moderemoved -- no longer necessary since reducer events are no longer broadcastCallReducerFlagsremoved --NoSuccessNotifyandset_reducer_flags()are gone- Event tables introduced -- a new table type for publishing transient events to subscribers
Reducer Callbacks
What changed
In 1.0, you could register global callbacks on reducers that would fire whenever any client called that reducer and you were subscribed to affected rows:
- TypeScript
- C#
- Rust
// 1.0 -- REMOVED in 2.0
conn.reducers.onDealDamage((ctx, { target, amount }) => {
console.log(`Someone called dealDamage with args: (${target}, ${amount})`);
});// 1.0-style global reducer callback semantics (no longer true in 2.0)
conn.Reducers.OnDealDamage += (ctx, target, amount) =>
{
Console.WriteLine($"Someone called DealDamage with args: ({target}, {amount})");
};// 1.0 -- REMOVED in 2.0
conn.reducers.on_deal_damage(|ctx, target, amount| {
println!("Someone called deal_damage with args: ({target}, {amount})");
});In 2.0, global reducer callbacks no longer exist. The server does not broadcast reducer argument data to other clients. Instead, you have two options:
Option A: Per-call result callbacks (_then())
If you only need to know the result of a reducer you called, you can await the result or use the _then() variant:
- TypeScript
- C#
- Rust
try {
await ctx.reducers.dealDamage({ target, amount });
console.log(`You called dealDamage with args: (${target}, ${amount})`);
} catch (err) {
if (err instanceof SenderError) {
console.log(`You made an error: ${err}`)
} else if (err instanceof InternalError) {
console.log(`The server had an error: ${err}`);
}
}// 2.0 -- per-call callback on the calling connection
conn.Reducers.OnDealDamage += (ctx, _, _) =>
{
if (ctx.Event.Status is Status.Committed)
{
Console.WriteLine("Reducer succeeded");
}
else if (ctx.Event.Status is Status.Failed failed)
{
Console.WriteLine($"Reducer failed: {failed}");
}
else if (ctx.Event.Status is Status.OutOfEnergy)
{
Console.WriteLine("Reducer failed: out of energy");
}
};
conn.Reducers.DealDamage(target, amount);// 2.0 -- per-call callback
ctx.reducers.deal_damage_then(target, amount, |ctx, result| {
match result {
Ok(Ok(())) => println!("Reducer succeeded"),
Ok(Err(err)) => println!("Reducer failed: {err}"),
Err(internal) => println!("Internal error: {internal:?}"),
}
}).unwrap();The fire-and-forget form still works:
// 2.0 -- fire and forget (unchanged)
ctx.reducers.deal_damage(target, amount).unwrap();
Option B: Event tables (recommended for most use cases)
If you need other clients to observe that something happened (the primary use case for 1.0 reducer callbacks), create an event table and insert into it from your reducer.
- TypeScript
- C#
- Rust
Server (module) -- before:
// 1.0 server -- reducer args were automatically broadcast
spacetimedb.reducer('deal_damage', { target: t.identity(), amount: t.u32() }, (ctx, { target, amount }) => {
// update game state
});Server (module) -- after:
// 2.0 server -- explicitly publish events via an event table
const damageEvent = table({ event: true }, {
target: t.identity(),
amount: t.u32(),
})
const spacetimedb = schema({ damageEvent });
export const dealDamage = spacetimedb.reducer(damageEvent.rowType, (ctx, { target, amount }) => {
// update game state
});Server (module) -- before:
// 1.0 server -- reducer args were automatically broadcast
[SpacetimeDB.Reducer]
public static void DealDamage(ReducerContext ctx, Identity target, uint amount)
{
// update game state...
}Server (module) -- after:
// 2.0 server -- explicitly publish events via an event table
[SpacetimeDB.Table(Accessor = "DamageEvent", Public = true, Event = true)]
public partial struct DamageEvent
{
public Identity Target;
public uint Amount;
}
[SpacetimeDB.Reducer]
public static void DealDamage(ReducerContext ctx, Identity target, uint amount)
{
// update game state...
ctx.Db.DamageEvent.Insert(new DamageEvent
{
Target = target,
Amount = amount,
});
}Server (module) -- before:
// 1.0 server -- reducer args were automatically broadcast
#[spacetimedb::reducer]
fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
// update game state...
}Server (module) -- after:
// 2.0 server -- explicitly publish events via an event table
#[spacetimedb::table(accessor = damage_event, public, event)]
pub struct DamageEvent {
pub target: Identity,
pub amount: u32,
}
#[reducer]
fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
// update game state...
ctx.db.damage_event().insert(DamageEvent { target, amount });
}- TypeScript
- C#
- Rust
Client -- before:
// 1.0 client -- global reducer callback
conn.reducers.onDealDamage((ctx, { target, amount }) => {
playDamageAnimation(target, amount);
});Client -- after:
// 2.0 client -- event table callback
// Note that although this callback fires, the `damageEvent`
// table will never have any rows present in the client cache
conn.db.damageEvent().onInsert((ctx, { target, amount }) => {
playDamageAnimation(target, amount);
});Client -- before:
// 1.0 client -- global reducer callback
conn.Reducers.OnDealDamage += (ctx, target, amount) =>
{
PlayDamageAnimation(target, amount);
};Client -- after:
// 2.0 client -- event table callback
conn.Db.DamageEvent.OnInsert += (ctx, damageEvent) =>
{
PlayDamageAnimation(damageEvent.Target, damageEvent.Amount);
};Client -- before:
// 1.0 client -- global reducer callback
conn.reducers.on_deal_damage(|ctx, target, amount| {
play_damage_animation(target, amount);
});Client -- after:
// 2.0 client -- event table callback
// Note that although this callback fires, the `damage_event`
// table will never have any rows present in the client cache
conn.db.damage_event().on_insert(|ctx, event| {
play_damage_animation(event.target, event.amount);
});Why event tables are better
- Security: You control exactly what data is published. In 1.0, reducer arguments were broadcast to any subscriber of affected rows, which could accidentally leak sensitive data.
- 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 can be subscribed to with query builders (or SQL), and can be filtered per-client.
Event table details
- Event tables are always empty outside of a transaction. They don't accumulate rows.
- On the client,
count()always returns 0 anditer()is always empty. - Only
on_insertcallbacks are generated (noon_deleteoron_update). - The
eventkeyword in#[table(..., event)]marks the table as transient. - Event tables must be subscribed to explicitly (they are excluded from
subscribeToAllTables/SubscribeToAllTables/subscribe_to_all_tables).
Event Type Changes
What changed
- TypeScript
- C#
- Rust
In 1.0, table callbacks received { tag: 'Reducer'; value: ReducerEvent<Reducer> } with full reducer information when a reducer caused a table change. Non-callers could also receive { tag: 'UnknownTransaction' }.
In 2.0, the event model is simplified:
- The caller sees
{ tag: 'Reducer'; value: ReducerEvent<Reducer> }withtype ReducerEvent = { timestamp, status, reducer }in response to their own reducer calls. - Other clients see
{ tag: 'Transaction' }(no reducer details). { tag: 'UnknownTransaction' }is removed.
// 2.0 -- checking who caused a table change
conn.db.myTable().onInsert((ctx, row) => {
if (ctx.event.tag === 'Reducer') {
// This client called the reducer that caused this insert.
console.log(`Our reducer: ${ctx.event.value.reducer}`);
}
if (ctx.event.tag === 'Transaction') {
// Another client's action caused this insert.
}
});In 1.0, table callbacks could receive Event<Reducer>.Reducer with reducer information when a reducer caused a table change. Non-callers could also receive Event<Reducer>.UnknownTransaction.
In 2.0, for known reducer updates:
- The caller sees
Event<Reducer>.ReducerwithReducerEvent { Timestamp, Status, Reducer }. - Other clients see
Event<Reducer>.Transaction(no reducer details).
// 2.0 -- checking who caused a table change
conn.Db.Person.OnInsert += (ctx, row) =>
{
if (ctx.Event is Event<Reducer>.Reducer(var reducerEvent))
{
// This client called the reducer that caused this insert.
Console.WriteLine($"Our reducer: {reducerEvent.Reducer}");
}
else if (ctx.Event is Event<Reducer>.Transaction)
{
// Another client's action caused this insert.
}
};In 1.0, table callbacks received Event::Reducer with full reducer information when a reducer caused a table change. Non-callers could also receive Event::UnknownTransaction.
In 2.0, the event model is simplified:
- The caller sees
Event::ReducerwithReducerEvent { timestamp, status, reducer }in response to their own reducer calls. - Other clients see
Event::Transaction(no reducer details). Event::UnknownTransactionis removed.
// 2.0 -- checking who caused a table change
conn.db.my_table().on_insert(|ctx, row| {
match &ctx.event {
Event::Reducer(reducer_event) => {
// This client called the reducer that caused this insert.
println!("Our reducer: {:?}", reducer_event.reducer);
}
Event::Transaction => {
// Another client's action caused this insert.
}
_ => {}
}
});If you need metadata about reducers invoked by other clients, update your reducer code to emit an event using an event table.
Subscription API
In 2.0, the subscription API is largely the same, but you can now subscribe to the database with a typed query builder:
- TypeScript
- C#
- Rust
// 1.0
ctx.subscriptionBuilder()
.onApplied(ctx => { /* ... */ })
.onError((ctx, err) => { /* ... */ })
.subscribe(["SELECT * FROM my_table"]);// 2.0 -- Typed query builder
import { tables } from './module_bindings';
ctx.subscriptionBuilder()
.onApplied(ctx => { /* ... */ })
.onError((ctx, err) => { /* ... */ })
.subscribe([tables.myTable]);// 2.0 -- same as 1.0
conn.SubscriptionBuilder()
.OnApplied(_ => { /* ... */ })
.OnError((_, error) => { /* ... */ })
.AddQuery(q => q.From.Person())
.Subscribe();// 2.0 -- same as 1.0
ctx.subscription_builder()
.on_applied(|ctx| { /* ... */ })
.on_error(|ctx, error| { /* ... */ })
.add_query(|q| q.from.my_table())
.subscribe();Note that subscribing to event tables requires an explicit query:
- TypeScript
- C#
- Rust
// Event tables are excluded from subscribe_to_all_tables(), so subscribe explicitly:
import { tables } from "./module_bindings";
ctx.subscriptionBuilder()
.onApplied((ctx) => { /* ... */ })
.subscribe([tables.damageEvent]);// Subscribe explicitly to an event table:
conn.SubscriptionBuilder()
.OnApplied(_ => { /* ... */ })
.AddQuery(q => q.From.DamageEvent())
.Subscribe();// Event tables are excluded from subscribe_to_all_tables(), so subscribe explicitly:
ctx.subscription_builder()
.on_applied(|ctx| { /* ... */ })
.add_query(|q| q.from.damage_event())
.subscribe();Table Name Canonicalization
What changed
SpacetimeDB 2.0 no longer equates the canonical name of your tables and indexes with the accessor method you use in module or client code. The canonical name is largely an internal detail, but you may encounter it when making SQL queries, or in the migration plans printed by spacetime publish.
Updating source code: Change name to accessor in table definitions
- TypeScript
- C#
- Rust
The name option for table definitions is now used to overwrite the canonical name, and is optional. The name of the key passed to the schema function controls the method names you write in your module and client source code.
By default, the canonical name is derived from the accessor by converting it to snake case.
To migrate a 1.0 table definition to 2.0, remove name from the table options and pass an object to the schema functions.
// 1.0
const myTable = table({ name: "my_table", public: true });
const spacetimedb = schema(myTable); // NO LONGER VALID in 2.0// 2.0
const myTable = table({ public: true }); // Name is no longer required
const spacetimedb = schema({ myTable }); // NOTE! We are passing `{ myTable }`, not `myTable`
export default spacetimedb; // You must now also export the schema from your module.The Name argument on table and index attributes is now used to override the canonical SQL name and is optional. The Accessor argument controls the generated API names you use in module and client code.
By default, the canonical name is derived from the accessor using the module's case-conversion policy.
To migrate a 1.0 table definition to 2.0, replace Name = with Accessor = in table and index definitions:
// 1.0 style -- NO LONGER VALID in 2.0
[SpacetimeDB.Table(Name = "MyTable", Public = true)]
[SpacetimeDB.Index.BTree(Name = "Position", Columns = [nameof(X), nameof(Y)])]
public partial struct MyTable
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public uint Id;
public uint X;
public uint Y;
}
// 2.0
[SpacetimeDB.Table(Accessor = "MyTable", Public = true)]
[SpacetimeDB.Index.BTree(Accessor = "Position", Columns = [nameof(X), nameof(Y)])]
public partial struct MyTable
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public uint Id;
public uint X;
public uint Y;
}The name argument to table definitions is now used to overwrite the canonical name, and is optional. The accessor argument controls the method names you write in your module and client source code.
By default, the canonical name is derived from the accessor by converting it to snake case.
To migrate a 1.0 table definition to 2.0, replace name = with accessor = in the table and index definitions:
// 1.0 -- NO LONGER VALID in 2.0
#[spacetimedb::table(
name = my_table,
public,
index(
name = position,
btree(columns = [x, y]),
)
)]
struct MyTable {
#[primary_key]
#[auto_inc]
id: u32,
x: u32,
y: u32,
}
// 2.0
#[spacetimedb::table(
accessor = my_table,
public,
index(
accessor = position,
btree(columns = [x, y]),
)
)]
struct MyTable {
#[primary_key]
#[auto_inc]
id: u32,
x: u32,
y: u32,
}Auto-migrating existing databases
The new default behavior for canonicalizing names may not be compatible with existing 1.0 databases, as it may change the casing of table names, which would require a manual migration.
Option 1: Disable case conversion
To avoid this manual migration, configure the case conversion policy in your module to not convert, which will result in the same table names as a 1.0 module:
- TypeScript
- C#
- Rust
export const moduleSettings: ModuleSettings = {
caseConversionPolicy: CaseConversionPolicy.None,
};[SpacetimeDB.Settings]
public const SpacetimeDB.CaseConversionPolicy CASE_CONVERSION_POLICY =
SpacetimeDB.CaseConversionPolicy.None;use spacetimedb::CaseConversionPolicy;
#[spacetimedb::settings]
const CASE_CONVERSION_POLICY: CaseConversionPolicy = CaseConversionPolicy::None;Option 2: overwrite the name of individual tables
Alternatively, manually specify the correct canonical name of each table:
- TypeScript
- C#
- Rust
[SpacetimeDB.Table(Accessor = "MyTable", Name = "MyTable", Public = true)]
[SpacetimeDB.Index.BTree(Accessor = "Position", Columns = [nameof(X), nameof(Y)])]
public partial struct MyTable
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public uint Id;
public uint X;
public uint Y;
}#[spacetimedb::table(
accessor = my_table,
name = "MyTable",
public,
index(
accessor = position,
btree(columns = [x, y]),
)
)]
struct MyTable {
#[primary_key]
#[auto_inc]
id: u32,
x: u32,
y: u32,
}Clients connect with database name
- TypeScript
- C#
- Rust
When constructing a DbConnection to a remote database, you now use withDatabaseName to provide the database name, rather than withModuleName. This is a more accurate terminology.
// 1.0 -- NO LONGER CORRECT
const conn = DbConnection.builder()
.withUri("https://maincloud.spacetimedb.com")
.withModuleName("my-database")
// other options...
.build();
// 2.0
const conn = DbConnection.builder()
.withUri("https://maincloud.spacetimedb.com")
.withDatabaseName("my-database")
// other options...
.build()When constructing a DbConnection to a remote database, you now use WithDatabaseName to provide the database name, rather than WithModuleName. This is a more accurate terminology.
// 1.0 -- NO LONGER CORRECT
var conn = DbConnection.Builder()
.WithUri("https://maincloud.spacetimedb.com")
.WithModuleName("my-database")
// other options...
.Build();
// 2.0
var conn = DbConnection.Builder()
.WithUri("https://maincloud.spacetimedb.com")
.WithDatabaseName("my-database")
// other options...
.Build();When constructing a DbConnection to a remote database, you now use with_database_name to provide the database name, rather than with_module_name. This is a more accurate terminology.
// 1.0 -- NO LONGER CORRECT
let conn = DbConnection::builder()
.with_uri("https://maincloud.spacetimedb.com")
.with_module_name("my-database")
// other options...
.build()
.expect("Failed to connect");
// 2.0
let conn = DbConnection::builder()
.with_uri("https://maincloud.spacetimedb.com")
.with_database_name("my-database")
// other options...
.build()
.expect("Failed to connect");sender Is Now A Method, Not A Field
- TypeScript
- C#
- Rust
This change does not apply to TypeScript, where properties are indistinguishable from fields.
This change does not apply to C#, where properties are indistinguishable from fields.
In Rust modules, the sender of a request is no longer exposed via a field ctx.sender on ReducerContext, ProcedureContext, ViewContext or AnonymousViewContext. Instead, each of these types has a method ctx.sender() which returns the sender's identity.
// 1.0 -- NO LONGER CORRECT
#[spacetimedb::reducer]
fn my_reducer(ctx: &ReducerContext) {
let sender_identity = ctx.sender;
// Do stuff with `sender_identity`...
}
// 2.0
#[spacetimedb::reducer]
fn my_reducer(ctx: &ReducerContext) {
let sender_identity = ctx.sender();
// Do stuff with `sender_identity`...
}Only Primary Keys Have Update Methods
- TypeScript
- C#
- Rust
In 2.0 modules, only columns with a .primaryKey() constraint expose an update method, whereas previously, .unique() constraints also provided that method. The previous behavior led to confusion, as only updates which preserved the value in the primary key column resulted in onUpdate callbacks being invoked on the client.
const myTable = table({ name: 'my_table' }, {
id: t.u32().unique(),
name: t.string(),
})
// 1.0 -- REMOVED in 2.0
spacetimedb.reducer('my_reducer', ctx => {
ctx.db.myTable.id.update({
id: 1,
name: "Foobar",
});
})
// 2.0 -- Perform a delete followed by an insert
// OR change the `.unique()` constraint into `.primaryKey()` constraint
spacetimedb.reducer('my_reducer', ctx => {
ctx.db.myTable.id.delete(1);
ctx.db.myTable.insert({
id: 1,
name: "Foobar"
});
})In 2.0 modules, only [SpacetimeDB.PrimaryKey] indexes expose an Update method, whereas previously, [SpacetimeDB.Unique] indexes also provided that method. The previous behavior led to confusion, as only updates which preserved the primary key value resulted in OnUpdate callbacks being invoked on the client.
Updates which preserve the primary key - update with the primary key index
[SpacetimeDB.Table(Accessor = "User")]
public partial struct User
{
[SpacetimeDB.PrimaryKey]
public Identity Identity;
[SpacetimeDB.Unique]
public string Name;
public uint ApplesOwned;
}
// 1.0 -- REMOVED in 2.0
[SpacetimeDB.Reducer]
public static void AddAppleOld(ReducerContext ctx, string name)
{
var user = ctx.Db.User.Name.Find(name).Value;
ctx.Db.User.Name.Update(new User
{
ApplesOwned = user.ApplesOwned + 1,
Identity = user.Identity,
Name = user.Name,
});
}
// 2.0
[SpacetimeDB.Reducer]
public static void AddApple(ReducerContext ctx, string name)
{
var user = ctx.Db.User.Name.Find(name).Value;
ctx.Db.User.Identity.Update(new User
{
ApplesOwned = user.ApplesOwned + 1,
Identity = user.Identity,
Name = user.Name,
});
}Updates which change the primary key - explicitly delete and insert
[SpacetimeDB.Table(Accessor = "User")]
public partial struct User
{
[SpacetimeDB.PrimaryKey]
public Identity Identity;
[SpacetimeDB.Unique]
public string Name;
public uint ApplesOwned;
}
// 1.0 -- REMOVED in 2.0
[SpacetimeDB.Reducer]
public static void ChangeUserIdentityOld(ReducerContext ctx, string name, Identity identity)
{
var user = ctx.Db.User.Name.Find(name).Value;
ctx.Db.User.Name.Update(new User
{
Identity = identity,
Name = user.Name,
ApplesOwned = user.ApplesOwned,
});
}
// 2.0
[SpacetimeDB.Reducer]
public static void ChangeUserIdentity(ReducerContext ctx, string name, Identity identity)
{
var user = ctx.Db.User.Name.Find(name).Value;
ctx.Db.User.Delete(user);
ctx.Db.User.Insert(new User
{
Identity = identity,
Name = user.Name,
ApplesOwned = user.ApplesOwned,
});
}In 2.0 modules, only #[primary_key] constraints expose an update method, whereas previously, #[unique] constraints also provided that method. The previous behavior led to confusion, as only updates which preserved the value in the primary key column resulted in on_update callbacks being invoked on the client.
Updates which preserve the primary key - update with the primary key index
#[spacetimedb::table(accessor = user)]
struct User {
#[primary_key]
identity: Identity,
#[unique]
name: String,
apples_owned: u32,
}
// 1.0 -- REMOVED in 2.0
#[spacetimedb::reducer]
fn add_apple(ctx: &ReducerContext, name: String) {
let user = ctx.db.user().name().find(&name).unwrap();
ctx.db.user().name().update(User {
apples_owned: user.apples_owned + 1,
..user
});
}
// 2.0
#[spacetimedb::reducer]
fn add_apple(ctx: &ReducerContext, name: String) {
let user = ctx.db.user().name().find(&name).unwrap();
ctx.db.user().identity().update(User {
apples_owned: user.apples_owned + 1,
..user
});
}Updates which change the primary key - explicitly delete and insert
#[spacetimedb::table(accessor = user)]
#[derive(Clone)]
struct User {
#[primary_key]
identity: Identity,
#[unique]
name: String,
apples_owned: u32,
}
// 1.0 -- REMOVED in 2.0
#[spacetimedb::reducer]
fn change_user_identity(ctx: &ReducerContext, name: String, identity: Identity) {
let user = ctx.db.user().name().find(&name).unwrap();
ctx.db.user().name().update(User {
identity,
..user
});
}
// 2.0
#[spacetimedb::reducer]
fn change_user_identity(ctx: &ReducerContext, name: String, identity: Identity) {
let user = ctx.db.user().name().find(&name).unwrap();
ctx.db.user().delete(user.clone());
ctx.db.user().insert(User {
identity,
..user
});
}Scheduled Functions Are Now Private
Scheduled reducers and procedures are now private by default, meaning that only the database owner and team collaborators can bypass the schedule table to invoke them manually.
Remove authorization logic from scheduled functions
Because scheduled reducers and procedures are now private, it's no longer necessary to explicitly check that the sender is the database itself.
- TypeScript
- C#
- Rust
const myTimer = table({ name: "my_timer", scheduled: 'runMyTimer' }, {
scheduledId: t.u64(),
scheduledAt: t.scheduleAt(),
});
const spacetimedb = schema(myTimer);
// 1.0 - SUPERFLUOUS IN 2.0
spacetimedb.reducer('runMyTimer', myTimer.rowType, (ctx, timer) => {
if (ctx.sender != ctx.identity) {
throw SenderError(`'runMyTimer' should only be invoked by the database!`);
}
// Do stuff
})const myTimer = table({ scheduled: 'runMyTimer' }, {
scheduledId: t.u64(),
scheduledAt: t.scheduleAt(),
});
const spacetimedb = schema({ myTimer });
// 2.0 -- Can only be called by the database
spacetimedb.reducer('runMyTimer', myTimer.rowType, (ctx, timer) => {
// Do stuff
})[SpacetimeDB.Table(Accessor = "MyTimer", Scheduled = nameof(RunMyTimer))]
public partial struct MyTimer
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong ScheduledId;
public ScheduleAt ScheduledAt;
}
// 1.0 - SUPERFLUOUS
[SpacetimeDB.Reducer]
public static void RunMyTimer(ReducerContext ctx, MyTimer timer)
{
if (ctx.Sender != ctx.Identity)
{
throw new Exception("`RunMyTimer` should only be invoked by the database!");
}
// Do stuff...
}
// 2.0
[SpacetimeDB.Reducer]
public static void RunMyTimer(ReducerContext ctx, MyTimer timer)
{
// Do stuff...
}#[spacetimedb::table(accessor = my_timer, scheduled(run_my_timer))]
struct MyTimer {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
scheduled_at: spacetimedb::ScheduleAt,
}
// 1.0 - SUPERFLUOUS IN 2.0
#[spacetimedb::reducer]
fn run_my_timer(ctx: &ReducerContext, timer: MyTimer) -> Result<(), String> {
if ctx.sender() != ctx.identity() {
return Err("`run_my_timer` should only be invoked by the database!".to_string());
}
// Do stuff...
Ok(())
}
// 2.0 -- Can only be called by the database
#[spacetimedb::reducer]
fn run_my_timer(ctx: &ReducerContext, timer: MyTimer) {
// Do stuff...
}Define wrappers for functions that are both scheduled and invoked by clients
In the rare event that you have a reducer or procedure which is intended to be invoked by both clients and a schedule table, define a new public reducer or procedure which wraps the scheduled function.
- TypeScript
- C#
- Rust
const myTimer = table({ scheduled: 'runMyTimerPrivate' }, {
scheduledId: t.u64(),
scheduledAt: t.scheduleAt(),
});
const spacetimedb = schema({ myTimer });
export const runMyTimerPrivate = spacetimedb.reducer(myTimer.rowType, (ctx, timer) => {
// Do stuff...
});
export const runMyTimer = spacetimedb.reducer(myTimer.rowType, (ctx, timer) => {
runMyTimerPrivate(ctx, timer);
});[SpacetimeDB.Table(Accessor = "MyTimer", Scheduled = nameof(RunMyTimerPrivate))]
public partial struct MyTimer
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong ScheduledId;
public ScheduleAt ScheduledAt;
}
[SpacetimeDB.Reducer]
public static void RunMyTimerPrivate(ReducerContext ctx, MyTimer timer)
{
// Do stuff...
}
[SpacetimeDB.Reducer]
public static void RunMyTimer(ReducerContext ctx, MyTimer timer)
{
RunMyTimerPrivate(ctx, timer);
}#[spacetimedb::table(accessor = my_timer, scheduled(run_my_timer_private))]
struct MyTimer {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
scheduled_at: spacetimedb::ScheduleAt,
}
#[spacetimedb::reducer]
fn run_my_timer_private(ctx: &ReducerContext, timer: MyTimer) {
// Do stuff...
}
#[spacetimedb::reducer]
fn run_my_timer(ctx: &ReducerContext, timer: MyTimer) {
run_my_timer_private(ctx, timer)
}Private Items Are Not Code-Generated By Default
Starting in SpacetimeDB 2.0, spacetime generate will not generate bindings for private tables or functions by default. These bindings were confusing, as only clients authenticated as the database owner or a collaborator could access them, with most clients seeing an error when trying to subscribe to a private table or invoke a private function.
Pass --include-private to spacetime generate
For clients which rely on generated bindings to private tables or functions, pass the --include-private flag to the spacetime generate CLI command.
Light Mode
What changed
In 1.0, light_mode prevented the server from sending reducer event data to a client (unless that client was the caller):
- TypeScript
- C#
- Rust
// 1.0 -- REMOVED in 2.0
DbConnection.builder()
.withLightMode(true)
// ...// 1.0
DbConnection.Builder()
.WithLightMode(true)
// ...// 1.0 -- REMOVED in 2.0
DbConnection::builder()
.with_light_mode(true)
// ...In 2.0, the server never broadcasts reducer argument data to any client, so light_mode is no longer necessary. Simply remove the call:
- TypeScript
- C#
- Rust
// 2.0
DbConnection.builder()
.withUri(uri)
.withDatabaseName(name)
// no withLightMode needed
.build()// 2.0
DbConnection.Builder()
.WithUri(uri)
.WithDatabaseName(name)
// no WithLightMode needed
.Build();// 2.0
DbConnection::builder()
.with_uri(uri)
.with_database_name(name)
// no with_light_mode needed
.build()CallReducerFlags
What changed
In 1.0, you could suppress success notifications for individual reducer calls:
- TypeScript
- C#
- Rust
// 1.0 -- REMOVED in 2.0
ctx.setReducerFlags(CallReducerFlags.NoSuccessNotify);
ctx.reducers.myReducer(args);In 2.0, the success notification is lightweight (just requestId and timestamp, no reducer args or full event data), so there is no need to suppress it. Remove any setReducerFlags calls and CallReducerFlags imports.
This migration item does not apply to C#. Before recent SpacetimeDB changes, C# had no public CallReducerFlags or set_reducer_flags equivalent.
// 1.0 -- REMOVED in 2.0
ctx.set_reducer_flags(CallReducerFlags::NoSuccessNotify);
ctx.reducers.my_reducer(args).unwrap();In 2.0, the success notification is lightweight (just request_id and timestamp, no reducer args or full event data), so there is no need to suppress it. Remove any set_reducer_flags calls and CallReducerFlags imports.
Quick Migration Checklist
- Remove all
ctx.reducers.on_<reducer>()calls- Replace with
_then()callbacks for your own reducer calls - Replace with event tables +
on_insertfor cross-client notifications
- Replace with
- Update
Event::UnknownTransactionmatches toEvent::Transaction - For each reducer whose args you were observing from other clients:
- Create an
#[table(..., event)]on the server - Insert into it from the reducer
- Subscribe to it on the client
- Use
on_insertinstead of the old reducer callback
- Create an
- Replace
name =withaccessor =in table and index definitions - Set your module's case conversion policy to
None - Change
with_module_nametowith_database_name - Change
ctx.sendertoctx.sender()- Only necessary in Rust modules.
- Remove
updatecalls on non-primary key unique indexes- When leaving the primary key value unchanged, update using the primary key index
- When altering the primary key value, delete and insert
- Remove superfluous auth logic from scheduled functions which are not called by clients
- Define wrappers around scheduled functions which are called by clients
- Use
spacetime generate --include-privateif you rely on bindings for private tables or functions - Remove
with_light_mode()fromDbConnectionBuilder - Remove
set_reducer_flags()calls andCallReducerFlagsimports - Remove
unstable::CallReducerFlagsfrom imports