Skip to main content
Databases

Turso

Give each Rivet Actor its own Turso Cloud database.

Turso Cloud hosts SQLite-compatible databases you reach over the network, and you can create as many as you need through its Platform API. That fits Rivet Actors well: each actor creates its own Turso database, opens a client to it in createVars, and deletes the database when the actor is destroyed.

ClienttodoListkey is the list idTursodatabaseactionsloads and writesOne per listRivet ActorCall

Quickstart

Create a Platform API token

Install the Turso CLI, sign in, and find your organization slug:

turso auth login
turso org list

Mint a Platform API token. The actor uses it to create and delete databases:

turso auth api-tokens mint rivet-todos

Install

npm install rivetkit @libsql/client @tursodatabase/api

@tursodatabase/api calls the Platform API. @libsql/client queries a database. The Platform API creates libSQL databases by default, which use that driver.

Configure Turso

Create .env with your organization slug and token:

# Your Turso organization slug from `turso org list`.
TURSO_ORG=

# Platform API token from `turso auth api-tokens mint rivet-todos`.
TURSO_PLATFORM_TOKEN=

# Group to create each actor's database in. Defaults to "default".
# TURSO_GROUP=default

Define the actor

Each todoList actor owns one list and one Turso database.

import { createClient as createDatabaseClient } from "@libsql/client";
import { createClient as createPlatformClient } from "@tursodatabase/api";
import { actor, setup } from "rivetkit";

export type Todo = {
	id: number;
	title: string;
	completed: boolean;
	createdAt: number;
};

type TodoDatabase = { name: string; url: string; authToken: string };

// One Platform API client for the whole process. It creates and deletes
// databases. Each actor queries its own database through c.vars.db.
const turso = createPlatformClient({
	org: requireEnv("TURSO_ORG"),
	token: requireEnv("TURSO_PLATFORM_TOKEN"),
});

export const todoList = actor({
	// Create this actor's own Turso database once, when the actor is created:
	// https://rivet.dev/docs/actors/state
	createState: async (c): Promise<{ database: TodoDatabase }> => {
		const name = databaseName(c.actorId);
		const hostname = await createDatabase(name);
		const { jwt } = await turso.databases.createToken(name, {
			authorization: "full-access",
		});
		return {
			database: { name, url: `libsql://${hostname}`, authToken: jwt },
		};
	},

	// Open a client to this actor's database each time the actor starts. vars
	// are never persisted, so live objects like clients belong here:
	// https://rivet.dev/docs/actors/state#ephemeral-state
	//
	// The client is never closed. In Node, a libsql:// URL uses HTTP: each
	// statement opens and closes its own stream, so an idle client holds no
	// connections, and dropping vars when the actor sleeps releases it.
	// close() would reject statements that in-flight actions have not sent yet.
	createVars: async (c) => {
		const db = createDatabaseClient({
			url: c.state.database.url,
			authToken: c.state.database.authToken,
		});
		await db.execute(`
			CREATE TABLE IF NOT EXISTS todos (
				id INTEGER PRIMARY KEY AUTOINCREMENT,
				title TEXT NOT NULL,
				completed INTEGER NOT NULL DEFAULT 0,
				created_at INTEGER NOT NULL
			)
		`);
		return { db };
	},

	// The actor is gone for good, so delete its database too:
	// https://rivet.dev/docs/actors/lifecycle#ondestroy
	onDestroy: async (c) => {
		await turso.databases.delete(c.state.database.name);
	},

	actions: {
		// Callable functions from clients: https://rivet.dev/docs/actors/actions
		// Each action awaits its statement, so a write is committed before the
		// action returns.
		addTodo: async (c, title: string): Promise<Todo> => {
			const createdAt = Date.now();
			const result = await c.vars.db.execute({
				sql: "INSERT INTO todos (title, created_at) VALUES (?, ?)",
				args: [title, createdAt],
			});
			return {
				id: Number(result.lastInsertRowid),
				title,
				completed: false,
				createdAt,
			};
		},

		toggleTodo: async (c, id: number) => {
			await c.vars.db.execute({
				sql: "UPDATE todos SET completed = NOT completed WHERE id = ?",
				args: [id],
			});
		},

		listTodos: async (c): Promise<Todo[]> => {
			const result = await c.vars.db.execute(
				"SELECT id, title, completed, created_at FROM todos ORDER BY id",
			);
			return result.rows.map((row) => ({
				id: Number(row.id),
				title: String(row.title),
				completed: Boolean(row.completed),
				createdAt: Number(row.created_at),
			}));
		},

		// Destroy the actor, which runs onDestroy:
		// https://rivet.dev/docs/actors/destroy
		deleteList: (c) => {
			c.destroy();
		},
	},
});

// Turso database names allow lowercase letters, digits, and dashes.
export function databaseName(actorId: string): string {
	return `todos-${actorId}`.toLowerCase().replace(/[^a-z0-9-]/g, "-");
}

// Creates the database, or finds it if a previous attempt to create this
// actor already did.
async function createDatabase(name: string): Promise<string> {
	try {
		const database = await turso.databases.create(name, {
			group: process.env.TURSO_GROUP ?? "default",
		});
		return database.hostname;
	} catch (error) {
		if ((error as { status?: number }).status !== 409) throw error;
		return (await turso.databases.get(name)).hostname;
	}
}

function requireEnv(name: string): string {
	const value = process.env[name];
	if (!value) throw new Error(`${name} is not set. See .env.example.`);
	return value;
}

// Register actors and start the server: https://rivet.dev/docs/setup
export const registry = setup({
	use: { todoList },
});

registry.start();

Run it

Start the server, then call the actor from a client:

npx tsx --env-file=.env src/index.ts
import { createClient } from "rivetkit/client";
import type { registry } from "../src/index.ts";

const client = createClient<typeof registry>({
	endpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
});

// Each key is its own todo list actor with its own Turso client.
const list = client.todoList.getOrCreate(["groceries"]);

const milk = await list.addTodo("Buy milk");
await list.addTodo("Buy eggs");
await list.toggleTodo(milk.id);
console.log("todos:", await list.listTodos());

// Destroying the actor deletes its rows from Turso and closes its client.
await list.deleteList();
console.log("deleted the groceries list");
npx tsx scripts/client.ts

Database lifecycle

HookWhat it does
createStateRuns once, when the actor is created. Creates the actor’s database and a token scoped to it, and saves both in state. A database left over from a failed creation attempt is reused.
createVarsRuns on every start. Opens a client to the database on c.vars and creates the table if it does not exist. When the actor sleeps, c.vars is dropped, and the next wake opens a new client.
onDestroyDeletes the database.

The client lives on c.vars because it is a live object. Ephemeral variables are never persisted, so each start gets a fresh client.

Databases are named todos-<actor id>, so a new actor with a reused key gets a new database. Each one counts toward your Turso plan’s database limit.

Why the client is never closed

Every action awaits its statement. execute() resolves only after the server returns the result, so the write is committed before the action returns.

In Node, @libsql/client sends libsql:// URLs over HTTP. Each statement opens a stream and closes it when its result arrives, and connections come from Node’s shared fetch pool. An idle client holds no connections, so dropping c.vars releases it.

Calling close() in onSleep or onDestroy would not release anything more, and it can break writes. close() is synchronous and does not flush: it rejects statements the client has not sent yet. onSleep and onDestroy run alongside in-flight actions during the shutdown sequence, so a write that is still queued would fail.

This relies on the HTTP transport. A wss:// URL keeps a WebSocket open, and an interactive transaction holds a server-side stream until it commits or rolls back. Close those explicitly, after every statement that uses them has settled.

onDestroy is best effort: if the process crashes during destroy, the database stays and must be deleted with the Turso CLI or Platform API.

Credentials

The Platform API token can create and delete every database in its organization, so it stays in the server’s environment. Each actor keeps only the token for its own database in its state.

Startup timeout

createVars must finish within createVarsTimeout, which defaults to 5 seconds. Its first query opens the connection to Turso, so a node far from the database’s region spends part of that budget on the round trip. Raise options.createVarsTimeout if starts time out. See Options.

See the Turso Platform API and TypeScript reference for the full APIs.

Local disk

This guide queries Turso Cloud over the network, so nodes need no local disk. Turso’s local options keep a database file on the node’s disk instead: embedded replicas, @tursodatabase/database, and @tursodatabase/sync.

If you use one of them, verify that your platform gives nodes a local disk. Some serverless platforms do not by default. Google Cloud Run, for example, uses an in-memory file system, so the database file uses the instance’s memory and is lost when the instance stops. Rivet Cloud provides local disk to nodes, so local Turso databases work there without extra setup.