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.
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
| Hook | What it does |
|---|---|
createState | Runs 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. |
createVars | Runs 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. |
onDestroy | Deletes 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.
Testing
The test creates real Turso databases, so it reads the same environment variables and is skipped without them. It checks that each list gets its own database and that destroying a list deletes it:
import { createClient } from "@tursodatabase/api";
import { setupTest } from "rivetkit/test";
import { expect, test, vi } from "vitest";
// Each actor creates a real Turso database, so this test needs Turso credentials.
test.skipIf(!process.env.TURSO_PLATFORM_TOKEN)(
"each list gets its own database, deleted with the list",
async (ctx) => {
const { registry, databaseName } = await import("../src/index.ts");
const { client } = await setupTest(ctx, registry);
const turso = createClient({
org: process.env.TURSO_ORG as string,
token: process.env.TURSO_PLATFORM_TOKEN as string,
});
const groceries = client.todoList.getOrCreate(["groceries"]);
const chores = client.todoList.getOrCreate(["chores"]);
const milk = await groceries.addTodo("Buy milk");
await groceries.addTodo("Buy eggs");
await chores.addTodo("Do laundry");
await groceries.toggleTodo(milk.id);
// Separate databases, so both lists number their rows from 1.
expect(await groceries.listTodos()).toMatchObject([
{ id: 1, title: "Buy milk", completed: true },
{ id: 2, title: "Buy eggs", completed: false },
]);
expect(await chores.listTodos()).toMatchObject([
{ id: 1, title: "Do laundry" },
]);
const groceriesDb = databaseName(await groceries.resolve());
const choresDb = databaseName(await chores.resolve());
await expect(turso.databases.get(groceriesDb)).resolves.toBeDefined();
await groceries.deleteList();
await chores.deleteList();
// Destroy is asynchronous, so poll until onDestroy has deleted both databases.
await vi.waitFor(
async () => {
const names = (await turso.databases.list()).map(
(db) => db.name,
);
expect(names).not.toContain(groceriesDb);
expect(names).not.toContain(choresDb);
},
{ timeout: 30_000, interval: 1_000 },
);
},
);
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.