Deprecated State
Migrate actors off the deprecated c.state and c.conn.state APIs to SQLite and c.vars.
c.state and c.conn.state were deprecated in 3.0 and will be removed in a future version. They still work today, so you can migrate at your own pace.
What is deprecated
Earlier versions of Rivet Actors stored durable data in two places that Rivet saved for you:
- Actor state (
c.state): a single serializable value defined withstateorcreateState. Rivet wrote it to storage on a throttle and loaded it again on every start. - Connection state (
c.conn.state): a value per connection defined withconnStateorcreateConnState.
The related options onStateChange, c.saveState(), stateSaveInterval and createConnStateTimeout are deprecated with them.
The Rust SDK no longer has actor state or connection state APIs. Store durable data with ctx.sql() and in-memory data in fields on your actor struct.
import { actor } from "rivetkit";
// An actor that still uses the deprecated `state` and `connState` options
const counter = actor({
state: { count: 0 },
connState: { joinedAt: 0 },
onConnect: (c, conn) => {
conn.state.joinedAt = Date.now();
},
actions: {
increment: (c) => {
c.state.count += 1;
return c.state.count;
},
},
});
Use these instead:
- Durable data goes in SQLite (
c.db). - In-memory data goes in
c.vars. It is rebuilt on every start and lost when the actor sleeps or crashes. - Connection data is tracked from
onConnectandonDisconnect. See Connection state.
Migrate actor state to SQLite
Move existing data with a SQLite migration. It reads the old state once, writes it into your own tables, and from then on the actor only uses SQLite.
- Remove
stateorcreateStatefrom the actor definition. - Add a
dbwith anonMigratethat creates your tables and copies the old state into them. - Read and write the new tables from your actions. See Update data in SQL.
import { actor } from "rivetkit";
import { db } from "rivetkit/db";
import { decodeLegacyState } from "./decode-legacy-state";
const counter = actor({
// `state` is removed from the actor definition
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
count INTEGER NOT NULL
);
`);
// Copy the old `c.state` into the new table once. `onMigrate` runs on
// every start, so skip the copy when the row already exists.
const existing = (await db.execute(
"SELECT 1 FROM counter WHERE id = 1",
)) as unknown[];
if (existing.length === 0) {
const legacy = (await db.execute(
"SELECT state FROM _rivet_actor_state WHERE id = 1",
)) as { state: Uint8Array }[];
const old =
legacy.length > 0 && legacy[0].state.length > 0
? (decodeLegacyState(legacy[0].state) as {
count: number;
})
: { count: 0 };
await db.execute(
"INSERT INTO counter (id, count) VALUES (1, ?)",
old.count,
);
}
},
}),
actions: {
get: async (c) => {
const [{ count }] = await c.db.execute<{ count: number }>(
"SELECT count FROM counter WHERE id = 1",
);
return count;
},
// Change the value in one statement so concurrent actions never overwrite each other
increment: async (c) => {
const [{ count }] = await c.db.execute<{ count: number }>(
"UPDATE counter SET count = count + 1 WHERE id = 1 RETURNING count",
);
return count;
},
},
});
import { decode } from "cbor-x";
/**
* Decodes the bytes stored in `_rivet_actor_state.state` into the value
* that `c.state` held.
*/
export function decodeLegacyState(bytes: Uint8Array): unknown {
return revive(decode(bytes));
}
// Values CBOR cannot represent directly are stored as `[tag, data]` pairs
function revive(value: unknown): unknown {
if (Array.isArray(value)) {
const [tag, data] = value;
if (value.length === 2 && typeof tag === "string" && tag.startsWith("$")) {
if (tag === "$BigInt") return BigInt(data);
if (tag === "$Undefined") return undefined;
if (tag === "$Set") return new Set((data as unknown[]).map(revive));
if (tag === "$Uint8Array") return base64ToBytes(data);
if (tag === "$ArrayBuffer") return base64ToBytes(data).buffer;
// A user array whose first item starts with `$` is escaped as `$$`
if (tag.startsWith("$$")) return [tag.slice(1), revive(data)];
}
return value.map(revive);
}
if (value instanceof Map) {
return new Map([...value].map(([k, v]) => [revive(k), revive(v)]));
}
if (value !== null && typeof value === "object" && value.constructor === Object) {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, revive(v)]),
);
}
return value;
}
function base64ToBytes(base64: string): Uint8Array {
return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
}
onMigrate runs on every start inside a transaction, so make the copy idempotent. The example skips it once the new row exists. The decoder needs the cbor-x package:
npm install cbor-x
Where the old state lives
Actor state is stored in the actor’s SQLite database in a table managed by Rivet. This table and its data will never be dropped, even after c.state is removed, so you can migrate out of it at any point:
CREATE TABLE _rivet_actor_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
state BLOB NOT NULL
) STRICT
- There is at most one row, with
id = 1. An actor that never had state may have no row or an empty blob. stateis the value ofc.stateencoded as CBOR.BigInt,Uint8Array,ArrayBuffer,Setandundefinedare stored as two-element arrays such as["$BigInt", "42"].decodeLegacyStateabove turns them back into their original types. This tag encoding applies to TypeScript actors.- Only read from
_rivet_*tables. Do not write to or drop them; Rivet manages them.
Update data in SQL
c.state changes ran synchronously, so two actions never interleaved in the middle of one. Every SQLite query is awaited and actions run concurrently, so an action can pause between reading a value and writing it back while another action changes it. Copying data into c.vars, changing the copy and writing it back has the same problem and loses updates.
Make each change one statement that computes the new value in SQL and returns it with RETURNING. Put the check in the WHERE clause so the check and the change cannot be split, and run statements that must commit together in c.db.transaction:
import { actor, UserError } from "rivetkit";
import { db } from "rivetkit/db";
const wallet = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS wallet (
id INTEGER PRIMARY KEY CHECK (id = 1),
balance INTEGER NOT NULL
);
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
`);
await db.execute(
"INSERT OR IGNORE INTO wallet (id, balance) VALUES (1, 0)",
);
},
}),
actions: {
balance: async (c) => {
const [{ balance }] = await c.db.execute<{ balance: number }>(
"SELECT balance FROM wallet WHERE id = 1",
);
return balance;
},
// The check and the change run in one statement, so two withdrawals
// can never both pass the check
withdraw: async (c, amount: number) => {
const rows = await c.db.execute<{ balance: number }>(
"UPDATE wallet SET balance = balance - ? WHERE id = 1 AND balance >= ? RETURNING balance",
amount,
amount,
);
if (rows.length === 0) {
throw new UserError("Insufficient balance", {
code: "insufficient_balance",
});
}
return rows[0].balance;
},
// Statements that must commit together run in one transaction
deposit: async (c, amount: number) => {
return await c.db.transaction(
async (tx) => {
await tx.execute(
"INSERT INTO ledger (amount, created_at) VALUES (?, ?)",
amount,
Date.now(),
);
const [{ balance }] = await tx.execute<{ balance: number }>(
"UPDATE wallet SET balance = balance + ? WHERE id = 1 RETURNING balance",
amount,
);
return balance;
},
{ name: "deposit" },
);
},
},
});
Queries run in the actor’s own process, so reading from SQLite in each action is fast. Unlike c.state, a write is durable as soon as c.db.execute resolves.
Realtime loops
An actor that changes data on every tick, such as a game loop or a physics world, keeps the live simulation in c.vars and saves a snapshot to SQLite on an interval. c.state did this for you: it was saved about once per second by default (stateSaveInterval), so a crash lost up to a second of progress.
- Load the last snapshot in
createVars. - Save a snapshot from the loop once the interval has passed, and once more when the loop exits.
- Write each snapshot in one
c.db.transactionso a crash never leaves half of one.
import { actor } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
// A crash loses at most this much progress
const SNAPSHOT_INTERVAL_MS = 1_000;
const TICK_MS = 50;
type Ball = { id: string; x: number; y: number; vx: number; vy: number };
interface WorldVars {
balls: Ball[];
// `onWake` stores its loop here so `onSleep` can wait for it.
loop: Promise<void> | null;
}
interface WorldContext {
vars: WorldVars;
db: RawAccess;
aborted: boolean;
abortSignal: AbortSignal;
}
const world = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS balls (
id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
vx REAL NOT NULL,
vy REAL NOT NULL
);
`);
},
}),
// The live simulation runs in memory, starting from the last snapshot
createVars: async (c): Promise<WorldVars> => ({
balls: await c.db.execute<Ball>("SELECT id, x, y, vx, vy FROM balls"),
loop: null,
}),
onWake: (c) => {
c.vars.loop = runWorld(c).catch((error) => {
c.log.error({ msg: "world loop failed", error });
});
},
// `c.abortSignal` fires on shutdown, so the loop saves and exits on its own
onSleep: async (c) => {
await c.vars.loop;
},
actions: {
addBall: (c, ball: Ball) => {
c.vars.balls.push(ball);
},
balls: (c) => c.vars.balls,
},
});
async function runWorld(c: WorldContext): Promise<void> {
let lastSnapshotAt = Date.now();
while (!c.aborted) {
await delay(TICK_MS, c.abortSignal);
for (const ball of c.vars.balls) {
ball.x += ball.vx;
ball.y += ball.vy;
}
if (Date.now() - lastSnapshotAt >= SNAPSHOT_INTERVAL_MS) {
await saveSnapshot(c);
lastSnapshotAt = Date.now();
}
}
// Save once more on shutdown so a graceful stop loses nothing
await saveSnapshot(c);
}
// Replace the snapshot in one transaction so a crash never leaves half of one
async function saveSnapshot(c: WorldContext): Promise<void> {
await c.db.transaction(
async (tx) => {
await tx.execute("DELETE FROM balls");
for (const ball of c.vars.balls) {
await tx.execute(
"INSERT INTO balls (id, x, y, vx, vy) VALUES (?, ?, ?, ?, ?)",
ball.id,
ball.x,
ball.y,
ball.vx,
ball.vy,
);
}
},
{ name: "save-snapshot" },
);
}
// Resolve after `ms`, or as soon as the actor starts shutting down
function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const finish = () => {
clearTimeout(timer);
signal.removeEventListener("abort", finish);
resolve();
};
const timer = setTimeout(finish, ms);
signal.addEventListener("abort", finish, { once: true });
});
}
The interval bounds how much progress a crash can lose. Data tied to a live connection, such as a player’s current input, stays out of the snapshot. The loop starts from onWake; see run for that pattern.
Connection state
Replace connState and createConnState with lifecycle hooks that key data by conn.id. Validate params in onBeforeConnect as before.
Regular connections
A regular connection ends when the actor sleeps, so its data only needs to live in memory. Add it to c.vars in onConnect and remove it in onDisconnect:
import { actor } from "rivetkit";
interface Presence {
name: string;
joinedAt: number;
}
const room = actor({
// Live connections are tracked in memory, keyed by connection ID
createVars: () => ({ presence: new Map<string, Presence>() }),
onConnect: (c, conn) => {
const { name } = conn.params as { name: string };
c.vars.presence.set(conn.id, { name, joinedAt: Date.now() });
},
onDisconnect: (c, conn) => {
c.vars.presence.delete(conn.id);
},
actions: {
whoAmI: (c) => c.vars.presence.get(c.conn.id),
online: (c) => [...c.vars.presence.values()],
},
});
Hibernatable WebSockets
A hibernatable WebSocket stays open while the actor sleeps, and c.vars is rebuilt when it wakes. Store data for these connections in SQLite so it survives, and delete it in onDisconnect. A connection that closes while the actor is asleep may not trigger onDisconnect, so also delete rows whose conn_id is no longer in c.conns when the actor wakes:
import { actor } from "rivetkit";
import { db } from "rivetkit/db";
const room = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS presence (
conn_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
joined_at INTEGER NOT NULL
);
`);
},
}),
// Hibernatable connections outlive the actor's memory, so store their data in SQLite
onConnect: async (c, conn) => {
const { name } = conn.params as { name: string };
await c.db.execute(
"INSERT OR REPLACE INTO presence (conn_id, name, joined_at) VALUES (?, ?, ?)",
conn.id,
name,
Date.now(),
);
},
onDisconnect: async (c, conn) => {
await c.db.execute("DELETE FROM presence WHERE conn_id = ?", conn.id);
},
// A connection that closed while the actor slept may never reach
// `onDisconnect`, so drop rows for connections that are no longer open
onWake: async (c) => {
const rows = (await c.db.execute("SELECT conn_id FROM presence")) as {
conn_id: string;
}[];
for (const row of rows) {
if (!c.conns.has(row.conn_id)) {
await c.db.execute("DELETE FROM presence WHERE conn_id = ?", row.conn_id);
}
}
},
actions: {
whoAmI: async (c) => {
const rows = (await c.db.execute(
"SELECT name, joined_at FROM presence WHERE conn_id = ?",
c.conn.id,
)) as { name: string; joined_at: number }[];
return rows[0];
},
},
});
Hibernatable connection state is stored in the Rivet-managed _rivet_conn_state table, with conn_id TEXT as the key and the CBOR-encoded state BLOB. Migrate it in onMigrate with the same decoder if a connection must keep its data across the upgrade.