
Decentralized Diffie-Hellman Key Exchange System
Note: KeyPears is a work-in-progress open-source password manager. The solutions described here are part of our development process and may evolve before our official release.
Building a local-first application with Tauri 2.0, we needed a robust database solution for storing encrypted vault data on users' devices. We wanted:
After evaluating options, we chose Drizzle ORM with SQLite via the official tauri-plugin-sql. This combination gives us TypeScript-first development with the reliability of SQLite.
Unlike traditional Node.js environments where you have direct filesystem access
and can use drivers like better-sqlite3, Tauri's sandboxed environment
requires a different approach. Drizzle's standard migration tools assume direct
database access, but with Tauri, we need to go through the plugin system.
Here's how we solved it.
First, add the necessary packages:
# Production dependencies
pnpm add drizzle-orm @tauri-apps/plugin-sql
# Development dependencies
pnpm add -D drizzle-kit
Then add the Tauri plugin to your Rust dependencies in src-tauri/Cargo.toml:
[dependencies]
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
Tauri 2.0 requires explicit permission grants. Add SQL permissions to
src-tauri/capabilities/default.json:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"sql:default",
"sql:allow-load",
"sql:allow-execute",
"sql:allow-select",
"sql:allow-close"
]
}
Without these permissions, you'll get "not allowed" errors when trying to access the database.
Create your Drizzle schema at app/db/schema.ts:
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const vaults = sqliteTable("vaults", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
});
Since we can't use standard SQLite drivers in Tauri, we use Drizzle's
sqlite-proxy adapter. Create app/db/index.ts:
import { drizzle } from "drizzle-orm/sqlite-proxy";
import Database from "@tauri-apps/plugin-sql";
import * as schema from "./schema";
export async function getDb() {
return await Database.load("sqlite:keypears.db");
}
function isSelectQuery(sql: string): boolean {
return sql.trim().toLowerCase().startsWith("select");
}
export const db = drizzle<typeof schema>(
async (sql, params, method) => {
const sqlite = await getDb();
let rows: any = [];
if (isSelectQuery(sql)) {
rows = await sqlite.select(sql, params).catch((e) => {
console.error("SQL Error:", e);
return [];
});
} else {
rows = await sqlite.execute(sql, params).catch((e) => {
console.error("SQL Error:", e);
return [];
});
return { rows: [] };
}
rows = rows.map((row: any) => Object.values(row));
const results = method === "all" ? rows : rows[0];
await sqlite.close();
return { rows: results };
},
{ schema: schema, logger: true }
);
The proxy adapter translates Drizzle queries into calls to the Tauri SQL plugin.
Create drizzle.config.ts:
import type { Config } from "drizzle-kit";
export default {
schema: "./app/db/schema.ts",
out: "./app/db/migrations",
dialect: "sqlite",
} satisfies Config;
Add a script to package.json:
{
"scripts": {
"db:migrate": "drizzle-kit generate"
}
}
Here's the key part - implementing our own migration system. Create
app/db/migrate.ts:
import { getDb } from "./index";
// Dynamically import all SQL migration files
const migrationFiles = import.meta.glob<string>("./migrations/*.sql", {
query: "?raw",
import: "default",
eager: true,
});
// Create migrations tracking table
async function ensureMigrationsTable() {
const sqlite = await getDb();
await sqlite.execute(`
CREATE TABLE IF NOT EXISTS __drizzle_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
)
`);
await sqlite.close();
}
// Get list of applied migrations
async function getAppliedMigrations(): Promise<string[]> {
const sqlite = await getDb();
const rows = await sqlite
.select<Array<{ hash: string }>>(
"SELECT hash FROM __drizzle_migrations ORDER BY id"
)
.catch(() => []);
await sqlite.close();
return rows.map((row) => row.hash);
}
// Record migration as applied
async function recordMigration(hash: string) {
const sqlite = await getDb();
const timestamp = Date.now();
await sqlite.execute(
"INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)",
[hash, timestamp]
);
await sqlite.close();
}
// Execute SQL file
async function executeSqlFile(sqlContent: string) {
const sqlite = await getDb();
const statements = sqlContent
.split("--> statement-breakpoint")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const statement of statements) {
await sqlite.execute(statement).catch((e) => {
console.error("Migration error:", e);
throw e;
});
}
await sqlite.close();
}
export async function runMigrations() {
console.log("Running database migrations...");
try {
await ensureMigrationsTable();
const appliedMigrations = await getAppliedMigrations();
const migrationPaths = Object.keys(migrationFiles).sort();
const pendingMigrations = migrationPaths.filter((path) => {
const filename = path.split("/").pop() || path;
return !appliedMigrations.includes(filename);
});
if (pendingMigrations.length === 0) {
console.log("All migrations already applied");
return;
}
for (const path of pendingMigrations) {
const filename = path.split("/").pop() || path;
const migrationContent = migrationFiles[path];
console.log(`Executing migration: ${filename}`);
await executeSqlFile(migrationContent);
await recordMigration(filename);
console.log(`✓ Applied: ${filename}`);
}
console.log(`Successfully completed ${pendingMigrations.length} migration(s)`);
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
}
This implements Drizzle's migration tracking pattern:
__drizzle_migrations table to track applied migrationsIn your root component (app/root.tsx), use a clientLoader to run migrations
before rendering:
import { runMigrations } from "./db/migrate";
export async function clientLoader() {
await runMigrations();
return null;
}
export function HydrateFallback() {
return (
<div className="flex min-h-screen items-center justify-center">
<h1>Migrating the database...</h1>
</div>
);
}
React Router will show the fallback while migrations run, ensuring the database is ready before any component renders.
With everything set up, create type-safe model functions at
app/db/models/vault.ts:
import { db } from "../index";
import { vaults } from "../schema";
import { eq, count } from "drizzle-orm";
export interface Vault {
id: number;
name: string;
}
export async function createVault(name: string): Promise<Vault> {
const result = await db.insert(vaults).values({ name }).returning();
return result[0];
}
export async function getVault(id: number): Promise<Vault | undefined> {
const result = await db.select().from(vaults).where(eq(vaults.id, id));
return result[0];
}
export async function getVaults(): Promise<Vault[]> {
return await db.select().from(vaults);
}
export async function countVaults(): Promise<number> {
const result = await db.select({ count: count() }).from(vaults);
return result[0]?.count ?? 0;
}
When you modify your schema:
# 1. Update app/db/schema.ts
# 2. Generate new migration
pnpm run db:migrate
# 3. Restart app - migration runs automatically
During development, you can safely delete all migrations and regenerate them from scratch. Just delete the database file and migration files, then regenerate.
Before releasing v1.0:
After release, never delete migrations - only add new ones. Users will have the old migrations applied, and new migrations build incrementally.
The Tauri SQL plugin creates the database in the app's data directory:
~/Library/Application Support/{app-identifier}/keypears.db~/.local/share/{app-identifier}/keypears.db%APPDATA%\{app-identifier}\keypears.dbPermission errors: Make sure you've added all SQL permissions to
capabilities/default.json
Migration fails: Check browser console in the Tauri webview for detailed error messages
Type errors: Run pnpm run typecheck to catch issues before runtime
This setup gives us:
The combination of Drizzle's sqlite-proxy adapter with Tauri's SQL plugin
provides a robust foundation for local-first data storage. While we had to
implement our own migration runner, we followed Drizzle's patterns to ensure
compatibility and maintainability.