件数の多いデータを検索・並べ替えするなら、アプリ内に SQLite のファイルを持つのが手堅い方法です。公式の SQL プラグインを入れると、フロントエンドから execute()(書き込み)と select()(読み取り)で SQL を実行できます。導入と CRUD に加えて、JS の値がどう保存されるかと、複数の書き込みをまとめて確定するときの注意を説明します。値の埋め込み方は プリペアドステートメント、テーブル定義の変更は マイグレーション で扱います。
前提条件
npm run tauri add sql
cd src-tauri
cargo add tauri-plugin-sql --features sqlite
tauri add は機能を指定せずに追加するので、ドライバーを features で選びます(sqlite / mysql / postgres)。忘れると Database.load() が「invalid connection url: sqlite:app.db - No database driver enabled!」で失敗します。
[dependencies]
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
core:default に SQL の権限は含まれません。sql:default は allow-load / allow-select / allow-close だけなので、execute() 用に sql:allow-execute を足します。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"sql:default",
"sql:allow-execute"
]
}
この権限では開ける DB を絞れません。sql:allow-execute を外しても select() に渡した DELETE や DROP TABLE は実行されるので、読み取り専用の仕組みにもなりません。外部のページを表示するウィンドウには付けないでください。
1. フロントエンドから実装する (TypeScript)
DB を開いて CRUD する
sqlite: の後の相対パスはアプリの設定フォルダーが基準で、ファイルが無ければ作られます。load() は呼ぶたびに接続を開き直すので、1 回だけ呼んで使い回します。接続を作らない Database.get() は、load() の後でないと「database sqlite:app.db not loaded」になります。
import Database from '@tauri-apps/plugin-sql';
export type Todo = { id: number; title: string; done: number; created_at: string };
// load() は 1 回だけ。以降は同じ Promise を返す
let dbPromise: Promise<Database> | null = null;
export function getDb(): Promise<Database> {
dbPromise ??= Database.load('sqlite:app.db').then(async (db) => {
// 試すための簡易版。配布するアプリではマイグレーションで作る
await db.execute(`CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
return db;
});
return dbPromise;
}
// 追加: 採番された id を返す
export async function addTodo(title: string): Promise<number> {
const db = await getDb();
const r = await db.execute('INSERT INTO todos (title) VALUES ($1)', [title]);
return r.lastInsertId ?? 0; // UPDATE / DELETE の後の lastInsertId は当てにしない
}
// 参照: 列名がキーのオブジェクトの配列。0 件なら []
export async function listTodos(): Promise<Todo[]> {
const db = await getDb();
return db.select<Todo[]>('SELECT id, title, done, created_at FROM todos ORDER BY id DESC');
}
// 更新: 真偽値は 1 / 0 に直して渡す
export async function setDone(id: number, done: boolean): Promise<boolean> {
const db = await getDb();
const r = await db.execute('UPDATE todos SET done = $1 WHERE id = $2', [done ? 1 : 0, id]);
return r.rowsAffected === 1; // 該当なしでもエラーにはならない
}
// 削除
export async function removeTodo(id: number): Promise<void> {
const db = await getDb();
await db.execute('DELETE FROM todos WHERE id = $1', [id]);
}
JS の値がどう保存されるか
| 渡す値 | 保存される値 | 読み戻したとき |
|---|---|---|
'abc' | 文字列 | 'abc' |
30(整数でも) | 小数 30.0。INTEGER 列では 30、TEXT 列では '30.0' | 30 / '30.0' |
true / false | 文字列 'true' / 'false' | 'true' |
null | NULL | null |
| オブジェクト・配列 | JSON の文字列 | 文字列 |
true のまま渡すと WHERE done = 1 に一致しないので、setDone() のように 1 / 0 で渡します。BOOLEAN と宣言した列も読み戻すと 1 / 0 です。郵便番号のような数字だけの文字列は文字列のまま渡します。CURRENT_TIMESTAMP は UTC の 2026-09-12 02:15:07 形式です。
複数の書き込みをまとめて確定したいとき
execute('BEGIN') と execute('COMMIT') を別々に送る書き方は使いません。プラグインは 1 つの DB に複数の接続を持ち、呼び出しごとに空いている接続を使うので、後の文が別の接続に届くことがあります。並行して読み書きした後だと ROLLBACK が「cannot rollback - no transaction is active」で失敗し、途中の INSERT が残ります。1 回の execute() に BEGIN から COMMIT まで並べても、途中で失敗するとトランザクションが開いたまま残り、以後の書き込みが「database is locked」になります。
複数行の INSERT(VALUES ($1), ($2))のように 1 文で書けるものは 1 文にします(1 文なら失敗時に全体が取り消されます)。それを超える処理は Rust で行います。
2. バックエンドから実装する (Rust)
プラグインは lib.rs で登録します(tauri add が書き足していたら二重にしない)。Rust から同じ DB を触るときは、プラグインが開いた接続を DbInstances から借りて sqlx のトランザクションを使います。commit() の前に ? や return で抜けると、それまでの変更は取り消されます。src-tauri で cargo add sqlx@0.8 --features sqlite,runtime-tokio を実行し、cargo tree -i sqlx でプラグインと同じ版か確かめます。
use tauri::Manager;
use tauri_plugin_sql::{DbInstances, DbPool};
const DB_URL: &str = "sqlite:app.db"; // JS の Database.load() と同じ文字列
// プラグインが開いた接続を借りる(先に load() か preload が済んでいること)
async fn plugin_pool(app: &tauri::AppHandle) -> Result<sqlx::SqlitePool, String> {
let instances = app.state::<DbInstances>();
let map = instances.0.read().await;
match map.get(DB_URL) {
Some(DbPool::Sqlite(pool)) => Ok(pool.clone()),
_ => Err(format!("database {DB_URL} not loaded")),
}
}
/// 複数件をまとめて追加する。1 件でも失敗したら全部取り消す
#[tauri::command]
async fn import_todos(app: tauri::AppHandle, titles: Vec<String>) -> Result<Vec<i64>, String> {
let pool = plugin_pool(&app).await?;
let mut tx = pool.begin().await.map_err(|e| e.to_string())?;
let mut ids = Vec::new();
for title in titles {
if title.trim().is_empty() {
return Err("空のタイトルがあるので中止しました".into()); // tx が破棄され、INSERT も取り消される
}
let r = sqlx::query("INSERT INTO todos (title) VALUES (?)")
.bind(title)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
ids.push(r.last_insert_rowid());
}
tx.commit().await.map_err(|e| e.to_string())?;
Ok(ids)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_sql::Builder::default().build())
.invoke_handler(tauri::generate_handler![import_todos])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
JS の load() より先にコマンドが呼ばれうるなら、tauri.conf.json で起動時に開かせます。
{
"plugins": {
"sql": {
"preload": ["sqlite:app.db"]
}
}
}
import { invoke } from '@tauri-apps/api/core';
const ids = await invoke<number[]>('import_todos', { titles: ['牛乳を買う', '請求書を送る'] });
console.log(ids); // [3, 4] のように、追加された id が返る
動作確認
npm run tauri dev で起動し、1 章の関数を呼んだ後に次のコードで中身と保存先を確かめます。load() は済んでいるので get() で足ります。
import Database from '@tauri-apps/plugin-sql';
import { appConfigDir } from '@tauri-apps/api/path';
const db = Database.get('sqlite:app.db'); // load() 済みの DB を指すだけ
console.log('保存先:', await appConfigDir());
console.log(await db.select('SELECT id, title, done, created_at FROM todos ORDER BY id'));
保存先: C:\Users\me\AppData\Roaming\com.example.myapp
[{ id: 1, title: "牛乳を買う", done: 1, created_at: "2026-09-12 02:15:07" }, { id: 2, title: "請求書を送る", done: 0, created_at: "2026-09-12 02:15:09" }]
実行中は app.db の横に app.db-wal と app.db-shm も並びます。直近の書き込みは -wal 側にあることがあるので、ファイルの複製はアプリを閉じてから行います。
よくあるエラーと対処法
- 「sql.execute not allowed. Permissions associated with this command: sql:allow-execute」: 権限の追加漏れです。リリースビルドでは「Command plugin:sql|execute not allowed by ACL」だけになります。
- 「plugin sql not found」:
lib.rsでプラグインを登録していません。 - 「database sqlite:app.db not loaded」:
get()や Rust のコマンドをload()より先に使いました。load()を待つかpreloadを設定します。 - 「error returned from database: (code: 1) no such table: todos」: テーブルが無いか、
sqlite:./app.dbのように別の文字列で開いて別のファイルを見ています。 - 「error returned from database: (code: 2067) UNIQUE constraint failed: users.email」: 一意制約の違反です。
(code: 2067)の部分で種類を見分けられます。
OS ごとの違いと注意点
- 保存先: Windows は
%APPDATA%\<identifier>、macOS は~/Library/Application Support/<identifier>、Linux は~/.config/<identifier>です。tauri.conf.jsonのidentifierを変えると空の DB から始まります。 close()の範囲: 引数なしのdb.close()は開いている全 DB を閉じます。以後の呼び出しはload()し直すまで「attempted to acquire a connection on a closed pool」で失敗します。- 外部キー: SQLite 単体では既定で無効ですが、このプラグインの接続では有効です。
ON DELETE CASCADEの子の行は親と一緒に消えます。 - 数個の設定値なら Store プラグイン、消えてもよいキャッシュなら LocalStorage / IndexedDB の方が手軽です。DB ファイルは暗号化されないので、パスワードなどの秘密は Stronghold に置きます。
