DEV Community

Huakun Shen
Huakun Shen

Posted on

Building a Local-First Tauri App with Drizzle ORM, Encryption, and Turso Sync (tauri-plugin-libsql)

Building a Local-First Tauri App with Drizzle ORM, Encryption, and Turso Sync

https://github.com/HuakunShen/tauri-plugin-libsql

I've been building desktop apps with Tauri for a while now, and one thing that consistently caused friction was the database layer. Tauri's official @tauri-apps/plugin-sql gets you SQLite, but it has no encryption support and no Drizzle integration that actually works inside a WebView. So I built my own plugin — tauri-plugin-libsql — and this post covers why I built it, the design decisions I made, and a few genuinely weird bugs I ran into along the way.


The Problem with Databases in Tauri

Tauri apps run your UI in a WebView. That WebView is essentially a browser — it has no Node.js fs module, no native bindings, no ability to open SQLite files directly. All access to the filesystem has to go through Tauri's IPC layer: your TypeScript code calls invoke(), and a Rust command handler does the actual work.

The official plugin, @tauri-apps/plugin-sql, handles this. It's fine for basic use. But I kept running into three walls:

1. No encryption. The plugin uses sqlx under the hood, and sqlx doesn't support SQLite encryption. If you want an encrypted database — say, to protect user data if the device is lost — you're on your own.

2. Drizzle ORM's migrator doesn't work in a WebView. I love Drizzle. Type-safe queries, a clean migration workflow, great ergonomics. But Drizzle's built-in migrator calls readMigrationFiles(), which uses Node's fs module at runtime. That API doesn't exist in a WebView. You hit a wall immediately when you try to use drizzle-kit migrate in a Tauri app.

3. No Turso support. I wanted to build local-first apps — data lives on disk, works offline, but syncs to a cloud database when connected. Turso's embedded replica mode does exactly this, but @tauri-apps/plugin-sql has no concept of it.


Why libsql

libsql is Turso's fork of SQLite. For the purposes of a Tauri plugin, it offers three things the standard sqlite3 crate doesn't:

  • Native encryption via the encryption feature flag — AES-256-CBC, no extra native libraries
  • Embedded replica mode — local SQLite file that syncs bidirectionally with Turso cloud
  • A clean async Rust API with a builder pattern that makes the three modes (local, replica, remote) easy to configure

The Rust API looks like this:

// Local only
let db = Builder::new_local("myapp.db").build().await?;

// Embedded replica (local file + Turso sync)
let db = Builder::new_remote_replica("local.db", "libsql://mydb.turso.io", "token")
    .build().await?;

// Initial sync on connect
db.sync().await?;
Enter fullscreen mode Exit fullscreen mode

Making Drizzle Work in a WebView

Drizzle has a lesser-known driver called sqlite-proxy. Instead of opening a database file directly, you give Drizzle a callback function. Drizzle generates SQL queries, calls your function with them, and you return the results. It was designed for HTTP-based SQLite proxies, but it works perfectly for Tauri's invoke() pattern.

import { drizzle } from "drizzle-orm/sqlite-proxy";
import { invoke } from "@tauri-apps/api/core";

const proxy = async (sql: string, params: unknown[], method: string) => {
  if (method === "run") {
    const result = await invoke("plugin:libsql|execute", { db: path, query: sql, values: params });
    return { rows: [] };
  }
  const rows = await invoke("plugin:libsql|select", { db: path, query: sql, values: params });
  return { rows: rows.map(Object.values) };
};

const db = drizzle(proxy, { schema });
Enter fullscreen mode Exit fullscreen mode

The plugin exports createDrizzleProxy(path) which wraps this pattern. You get full Drizzle — type-safe queries, joins, transactions, all of it — running against a local SQLite file via Rust, inside a Tauri WebView.


Solving the Migration Problem

Once Drizzle works, you want migrations. The standard drizzle-kit migrate reads .sql files from disk at runtime. In a WebView: no filesystem, no fs, dead end.

The solution is Vite's import.meta.glob. Instead of reading files at runtime, Vite inlines the SQL file contents directly into the JavaScript bundle at build time:

const migrations = import.meta.glob<string>("./drizzle/*.sql", {
  eager: true,
  query: "?raw",      // import as raw string
  import: "default",
});
Enter fullscreen mode Exit fullscreen mode

This gives you an object like { "./drizzle/0000_init.sql": "CREATE TABLE ...", ... }. You pass it to migrate(), which tracks applied migrations in a __drizzle_migrations table and runs pending ones in order:

await Database.load("sqlite:myapp.db");
await migrate("sqlite:myapp.db", migrations);

// Now safe to use Drizzle
const db = drizzle(createDrizzleProxy("sqlite:myapp.db"), { schema });
Enter fullscreen mode Exit fullscreen mode

The order matters. Querying before migrate() runs gives you "no such table" errors.


Encryption

The plugin supports two modes:

Plugin-level encryption — configured once in Rust, applies to all databases. The key never touches JavaScript:

let config = tauri_plugin_libsql::Config {
    base_path: Some(cwd),
    encryption: Some(EncryptionConfig {
        cipher: Cipher::Aes256Cbc,
        key: std::env::var("DB_KEY")
            .map(|k| k.into_bytes())
            .unwrap_or_default(),
    }),
};

tauri::Builder::default()
    .plugin(tauri_plugin_libsql::init_with_config(config))
Enter fullscreen mode Exit fullscreen mode

Per-database encryption — key passed from the frontend:

const db = await Database.load({
  path: "sqlite:secrets.db",
  encryption: {
    cipher: "aes256cbc",
    key: Array.from(myUint8Key32Bytes),
  },
});
Enter fullscreen mode Exit fullscreen mode

Plugin-level is the better choice for most apps — it keeps the key in Rust where JavaScript can't inspect it.


Turso Embedded Replica

This is where things get interesting. Turso's embedded replica mode maintains a local SQLite file that stays in sync with a Turso cloud database. Reads come from the local file (fast, offline-capable). Writes go to the remote. You pull the latest changes manually with sync().

For a Tauri app, this is the ideal architecture. Your app works offline by default. When the user is connected, you sync on launch and on demand.

const db = await Database.load({
  path: "sqlite:local.db",
  syncUrl: "libsql://mydb-org.turso.io",
  authToken: import.meta.env.VITE_TURSO_AUTH_TOKEN,
});

// migrations run against the local replica
await migrate(db.path, migrations);

// pull latest remote changes
await db.sync();
Enter fullscreen mode Exit fullscreen mode

Database.load() does an initial sync automatically on connect. Subsequent db.sync() calls pull incremental changes.


The Weird Bugs

Building this plugin involved a few bugs that were interesting enough to be worth writing up.

Bug 1: The IPC That Never Responded

After adding Turso support, I noticed that connecting with a badly-formatted URL caused the app to hang indefinitely on the loading spinner. The Rust terminal showed a panic:

thread 'tokio-runtime-worker' panicked at libsql-0.9.29/src/database/builder.rs:409:66:
called `Result::unwrap()` on an `Err` value: http::Error(InvalidUri(InvalidFormat))
Enter fullscreen mode Exit fullscreen mode

libsql's builder calls unwrap() internally when parsing the sync URL. This isn't a bug in the user's code — it's libsql panicking on an Err it should have returned. In Tauri, a panic inside an async command handler causes the IPC response to never be sent. The JavaScript await hangs forever. There's no timeout, no rejection — just silence.

The fix is catch_unwind from the futures crate:

use futures::FutureExt;
use std::panic::AssertUnwindSafe;

let db = AssertUnwindSafe(async move {
    // ... libsql builder calls ...
})
.catch_unwind()
.await
.map_err(|_| Error::InvalidDbUrl(
    "libsql panicked — check your URL format".into()
))??;
Enter fullscreen mode Exit fullscreen mode

The double ?? unwraps the panic result first (converted to Error), then the inner Result<Database, Error>. Now a bad URL gives a real error message instead of hanging.

Bug 2: execute_batch Silently Failing with Embedded Replicas

The migration runner originally used execute_batch() from libsql to run multiple SQL statements atomically. This worked fine for local databases. With an embedded replica, migrations appeared to succeed — no errors — but the tables didn't actually get created. The Drizzle migrations table would record the migration as applied, then the next query would fail with "no such table".

After a lot of debugging, it turned out that execute_batch() doesn't correctly route writes through the embedded replica's write path in libsql 0.9.x. Individual execute() calls inside an explicit BEGIN/COMMIT do work:

pub async fn batch(&self, queries: Vec<String>) -> Result<(), Error> {
    self.conn.execute("BEGIN", Params::None).await?;
    for query in &queries {
        if let Err(e) = self.conn.execute(query.as_str(), Params::None).await {
            let _ = self.conn.execute("ROLLBACK", Params::None).await;
            return Err(Error::Libsql(e));
        }
    }
    if let Err(e) = self.conn.execute("COMMIT", Params::None).await {
        let _ = self.conn.execute("ROLLBACK", Params::None).await;
        return Err(Error::Libsql(e));
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Tedious, but it works reliably.

Bug 3: The $state That Wasn't

This one was a Svelte 5 mistake in the demo app. I had let dbInstance: Database | null = null — a plain mutable variable, not a $state. The code that checked {#if dbInstance} in the template to show the "Reset DB" button was therefore non-reactive. Setting dbInstance in onMount had no effect on the template.

<!-- Before: dbInstance not reactive, button never shows -->
let dbInstance: Database | null = null;

<!-- After: reactive, button appears when DB is loaded -->
let dbInstance = $state<Database | null>(null);
Enter fullscreen mode Exit fullscreen mode

Svelte 5 doesn't warn about this in all cases — if the variable is set in onMount and never re-assigned elsewhere, you might not get a warning. The template just silently doesn't update.

Bug 4: Error State That Ate the Form

The CRUD error handling set error = "Failed to add: ..." on failures. The template used {:else if error} to show an error alert — which meant any failed add/toggle/delete replaced the entire todo form with the alert. The user had to refresh to get the form back.

The fix was to stop mixing fatal errors (load failure — should block the UI) with transient errors (write failure — should be a notification). Toast notifications handle the transient case; error state is now only set on fatal load failures.


The Demo App

The plugin ships with a two-panel demo that makes the local vs. remote difference tangible:

  • Left panel: Local SQLite. Writes are instant.
  • Right panel: Turso embedded replica. Enter your syncUrl and auth token, click Connect. Writes go to Turso — you can watch the latency.

Both panels share the same TodoList.svelte component. The difference is just which props are passed:

<!-- Local: instant writes -->
<TodoList dbFile="todos.db" position="bottom-left" />

<!-- Turso: network-latency writes -->
<TodoList
  dbFile="todos-turso.db"
  syncUrl={appliedSyncUrl}
  authToken={appliedAuthToken}
  position="bottom-right"
  onDisconnect={handleDisconnect}
/>
Enter fullscreen mode Exit fullscreen mode

The position prop routes sonner toasts to different corners so you can see which panel's events are which.


Installation

If you want to try it:

# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-libsql = "0.1.0"

# For Turso sync:
tauri-plugin-libsql = { version = "0.1.0", features = ["replication"] }
Enter fullscreen mode Exit fullscreen mode
npm install tauri-plugin-libsql-api
Enter fullscreen mode Exit fullscreen mode
// src-tauri/src/lib.rs
tauri::Builder::default()
    .plugin(tauri_plugin_libsql::init())
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
Enter fullscreen mode Exit fullscreen mode

The source is on GitHub. Contributions welcome, especially testing on Windows and Linux.


What's Next

A few things I'd like to add:

  • Auto-sync on network reconnect — Tauri has network status events; db.sync() could fire automatically
  • Conflict visibility — embedded replica is last-write-wins with no conflict UI
  • iOS/Android testing — the mobile stubs exist but haven't been verified

If you're building a Tauri app and have hit the same walls with the official SQL plugin, give it a try.

Top comments (0)