# Deprecated State

> **WARNING:** `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 with `state` or `createState`. 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 with `connState` or `createConnState`.

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.

examples/docs/actors-deprecated-state/before.ts:

```ts
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](/actors/docs/sqlite) (`c.db`).
- **In-memory data** goes in [`c.vars`](/actors/docs/state#ephemeral-state). It is rebuilt on every start and lost when the actor sleeps or crashes.
- **Connection data** is tracked from `onConnect` and `onDisconnect`. See [Connection state](#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.

1. Remove `state` or `createState` from the actor definition.
2. Add a `db` with an `onMigrate` that creates your tables and copies the old state into them.
3. Read and write the new tables from your actions. See [Update data in SQL](#update-data-in-sql).

actor.ts:

```ts
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;
		},
	},
});
```

decode-legacy-state.ts:

```ts
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:

```sh
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:

```sql
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.
- `state` is the value of `c.state` encoded as [CBOR](https://cbor.io). `BigInt`, `Uint8Array`, `ArrayBuffer`, `Set` and `undefined` are stored as two-element arrays such as `["$BigInt", "42"]`. `decodeLegacyState` above 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`:

examples/docs/actors-deprecated-state/update-in-sql.ts:

```ts
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.

1. Load the last snapshot in `createVars`.
2. Save a snapshot from the loop once the interval has passed, and once more when the loop exits.
3. Write each snapshot in one `c.db.transaction` so a crash never leaves half of one.

examples/docs/actors-state/sqlite-snapshot.ts:

```ts
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](/actors/docs/deprecated/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`:

examples/docs/actors-deprecated-state/conn-vars.ts:

```ts
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](/actors/docs/websocket-handler#websocket-hibernation) 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:

examples/docs/actors-deprecated-state/conn-sqlite.ts:

```ts
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.
