| name | tauri-plugin-libsql |
| description | Use tauri-plugin-libsql for SQLite database access in Tauri apps with Drizzle ORM, browser-safe migrations, optional AES-256-CBC encryption, and Turso embedded replica sync. Use when working on this plugin's source, writing apps that consume it, adding schema changes, debugging migration or query errors, configuring encryption, or setting up Turso remote sync. |
| version | 1.1.0 |
| license | MIT |
| metadata | {"tags":["tauri","sqlite","libsql","drizzle-orm","encryption","migrations","turso","replication"]} |
tauri-plugin-libsql
SQLite plugin for Tauri apps via libsql. Provides encryption, Drizzle ORM integration, a browser-safe migration runner, and Turso embedded replica sync.
Key Files
guest-js/index.ts — Database class, getConfig, re-exports
guest-js/drizzle.ts — createDrizzleProxy, createDrizzleProxyWithEncryption
guest-js/migrate.ts — migrate() function
src/commands.rs — Rust command handlers: load, execute, select, batch, sync, close
src/wrapper.rs — DbConnection (local / replica / remote, catch_unwind protection)
src/desktop.rs — Config struct, base_path resolution
src/lib.rs — Plugin init, command registration
src/error.rs — Error types incl. OperationNotSupported
examples/todo-list/ — Two-panel demo: local SQLite (left) + Turso sync (right)
Critical: Why a Custom Migrator Exists
drizzle-orm/sqlite-proxy/migrator calls readMigrationFiles() which reads from the filesystem at runtime. That API does not exist in a Tauri WebView (browser context). The plugin's migrate() function instead receives SQL content that Vite bundles into the app at build time via import.meta.glob.
Startup Sequence (always in this order)
await Database.load('sqlite:myapp.db');
await migrate('sqlite:myapp.db', migrations);
const db = drizzle(createDrizzleProxy('sqlite:myapp.db'), { schema });
Querying before migrate() causes "no such table" errors.
Full Usage Pattern
schema.ts
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const todos = sqliteTable('todos', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
completed: integer('completed').notNull().default(0),
createdAt: text('created_at').default(sql`(current_timestamp)`),
});
export type Todo = typeof todos.$inferSelect;
drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: './src/lib/schema.ts',
out: './drizzle',
});
Generate migrations
npx drizzle-kit generate
bun run db:generate
This creates drizzle/0000_xxx.sql, drizzle/0001_xxx.sql, etc. Commit these files.
App startup (Svelte example)
import { Database, migrate, createDrizzleProxy } from 'tauri-plugin-libsql-api';
import { drizzle } from 'drizzle-orm/sqlite-proxy';
import * as schema from './schema';
const migrations = import.meta.glob<string>('../drizzle/*.sql', {
eager: true,
query: '?raw',
import: 'default',
});
const dbPath = 'sqlite:myapp.db';
await Database.load(dbPath);
await migrate(dbPath, migrations);
const db = drizzle(createDrizzleProxy(dbPath), { schema });
Database Location
Relative paths resolve against base_path in the Rust plugin config:
- Default:
std::env::current_dir() — where the Tauri process is launched from
- Custom: set
base_path: Some(PathBuf::from(...)) in Config
- Absolute paths are used as-is
:memory: → in-memory database
Relative paths containing .. are normalised and validated. A path that would escape base_path (e.g. sqlite:../../etc/passwd) is rejected with InvalidDbUrl.
The demo app (src-tauri/src/lib.rs) explicitly sets base_path: Some(cwd) so the DB lands next to where bun run tauri dev is invoked.
Encryption
Option 1: Plugin-level (recommended — key stays in Rust)
let config = tauri_plugin_libsql::Config {
base_path: Some(cwd),
encryption: Some(tauri_plugin_libsql::EncryptionConfig {
cipher: tauri_plugin_libsql::Cipher::Aes256Cbc,
key: my_32_byte_vec,
}),
};
tauri::Builder::default()
.plugin(tauri_plugin_libsql::init_with_config(config))
...
The demo reads the key from LIBSQL_ENCRYPTION_KEY env var and pads/truncates to 32 bytes.
Option 2: Per-database (key passed from frontend)
const db = await Database.load({
path: 'sqlite:secrets.db',
encryption: {
cipher: 'aes256cbc',
key: Array.from(myUint8Array32),
},
});
With Drizzle + encryption
const db = drizzle(
createDrizzleProxyWithEncryption({
path: 'sqlite:encrypted.db',
encryption: { cipher: 'aes256cbc', key: myKey },
}),
{ schema }
);
Adding a New Column / Table (Migration Workflow)
- Edit
src/lib/schema.ts
- Run
npx drizzle-kit generate — creates a new numbered .sql file in drizzle/
- Commit the new migration file
- On next app launch,
migrate() detects and applies it automatically
Never manually edit existing migration files. Add new ones only.
Turso Embedded Replica
For local-first apps that sync with Turso cloud. Local reads are instant; writes go to the remote.
Enable
In your app's Cargo.toml:
tauri-plugin-libsql = { path = "...", features = ["replication"] }
Usage
const db = await Database.load({
path: 'sqlite:local.db',
syncUrl: 'libsql://mydb-org.turso.io',
authToken: 'your-turso-auth-token',
});
await migrate(db.path, migrations);
await db.sync();
Database.load() does an initial sync automatically. Use separate local files for local-only and replica databases — mixing them causes a "metadata file missing" error.
Troubleshooting Turso
| Symptom | Cause | Fix |
|---|
loading stuck forever | Bad URL causes libsql to panic internally; IPC never responds | Plugin catches this via catch_unwind and returns a proper error |
no such table after connecting | __drizzle_migrations has stale records from a previous run | Drop todos and __drizzle_migrations, re-run migrate() |
| "invalid local state: db file exists but metadata file does not" | Plain SQLite file being opened as an embedded replica | Use a separate dbFile for each mode |
batch() — Atomic DDL / DML
Execute multiple SQL statements in a single transaction. Use for DDL or bulk inserts. Do not use bound parameters ($1 placeholders) — use execute() for parameterised queries.
await db.batch([
'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)',
'CREATE INDEX idx_name ON users(name)',
]);
Note: execute_batch() from libsql does not correctly route writes through the embedded replica layer. The plugin uses individual execute() calls inside an explicit BEGIN/COMMIT instead.
Common Errors
| Error | Cause | Fix |
|---|
no such table: todos | migrate() not called before queries, or migration files missing | Check startup sequence; run drizzle-kit generate |
DatabaseNotLoaded | Query sent before Database.load() | Call Database.load() first |
DatabaseNotLoaded after close | createDrizzleProxy loaded flag doesn't reset on external close | Recreate the proxy after calling Database.close() |
Migration X failed | Bad SQL in a migration file | Check the .sql file; fix schema definition |
path '...' escapes the base directory | Relative path contains .. that exits base_path | Use a path that stays within the configured base directory |
| DB file not found | Wrong working directory | Check base_path config or launch directory |
| Encryption error on open | Wrong key for existing encrypted DB | Use exact same key as when DB was created |
libsql panicked building the database | Malformed syncUrl (spaces, wrong scheme, etc.) | Trim the URL; ensure it starts with libsql:// or https:// |
operation not supported: sync requires replication feature | db.sync() called without replication feature | Add features = ["replication"] to Cargo.toml |
Plugin Architecture
Frontend (TS) Rust Plugin
───────────────── ─────────────────────────
Database.load() ──invoke──▶ commands::load()
migrate() ──invoke──▶ commands::batch() (DDL in transaction)
db.execute() ──invoke──▶ commands::execute()
db.select() ──invoke──▶ commands::select()
db.batch() ──invoke──▶ commands::batch()
db.sync() ──invoke──▶ commands::sync()
db.close() ──invoke──▶ commands::close()
│
wrapper::DbConnection
│ catch_unwind (panic → proper Error)
├── open_local() — LibsqlBuilder::new_local
├── open_replica() — new_remote_replica + initial sync
└── open_remote() — LibsqlBuilder::new_remote
▼
libsql (SQLite / Turso)
Building the JS Package
After changing guest-js/ files:
npm run build
The demo app references the plugin as file:../../ so it picks up the built output automatically.
Permissions
Every Tauri command needs a permission. Default set in permissions/default.toml. To allow all commands in a capability:
{
"permissions": [
"libsql:allow-load",
"libsql:allow-execute",
"libsql:allow-select",
"libsql:allow-close"
]
}