アプリの更新で列やテーブルを足したくなったとき、利用者の手元には旧版のテーブルとデータがすでにあります。SQL プラグインでは、Rust 側に「版番号と SQL」の一覧を登録しておくと、DB を開いたときに未適用の版だけを順に実行し、適用した版を DB 内の _sqlx_migrations テーブルに記録します。登録の仕方、版の上げ方、失敗したときに DB がどうなるかと、その扱い方を説明します。
前提条件
SQL プラグインの導入(npm run tauri add sql と sqlite 機能)は SQLite データベース操作 (CRUD) のとおりです。マイグレーションは Database.load() の中で実行されるので、そのための追加の権限はありません(load は sql:default に含まれます)。アプリがテーブルに書き込むなら 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"
]
}
1. バックエンドから実装する (Rust)
版の一覧を登録する
version は版番号、description は記録用の名前、sql はその版で実行する SQL で、; で区切って複数の文を書けます。add_migrations() の第 1 引数は、JS の Database.load() に渡す文字列と完全に同じにします。SQLite の ALTER TABLE でできるのは列の追加・名前の変更・削除などに限られ、型や制約を変えるには v3 のようにテーブルを作り直します。
use tauri_plugin_sql::{Migration, MigrationKind};
const DB_URL: &str = "sqlite:app.db"; // JS の Database.load() と同じ文字列
fn migrations() -> Vec<Migration> {
vec![
// v1: 最初のリリース
Migration {
version: 1,
description: "create_notes",
sql: "CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);",
kind: MigrationKind::Up,
},
// v2: 列の追加。既存の行には DEFAULT の値が入る
Migration {
version: 2,
description: "add_pinned",
sql: "ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_notes_pinned ON notes (pinned);",
kind: MigrationKind::Up,
},
// v3: body の NOT NULL を外し、title を足す(制約の変更は作り直し)
Migration {
version: 3,
description: "rebuild_notes",
sql: "CREATE TABLE notes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
body TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO notes_new (id, body, pinned, created_at)
SELECT id, body, pinned, created_at FROM notes;
DROP TABLE notes;
ALTER TABLE notes_new RENAME TO notes;
CREATE INDEX idx_notes_pinned ON notes (pinned);",
kind: MigrationKind::Up,
},
]
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(DB_URL, migrations())
.build(),
)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
v1 のまま使っていた利用者が v3 のアプリを入れると、次に DB を開いたときに v2 と v3 が続けて実行されます。新規の利用者なら v1 から v3 まで実行されます。
版を上げるときの決まり
- 新しい版は一覧の末尾に、前より大きい番号で足す。 番号は連番でなくても構いません。
- 適用済みの版の SQL は 1 文字も変えない。 DB を開くときに記録と照合され、違うと「migration 2 was previously applied but has been modified」で開けなくなります。空白や改行コード(CRLF / LF)の違いも変更とみなされます。
descriptionは照合されません。 - 適用済みの版を一覧から消さない。 消すと「migration 2 was previously applied but is missing in the resolved migrations」になります。新しい版で開いた DB を古い版のアプリで開いたときも同じエラーです。
- 元に戻す処理も新しい版として足す。
MigrationKind::Downの版を登録しても、自動では実行されません。
長い SQL は include_str!("../migrations/0003_rebuild_notes.sql") でファイルに分けられます。その場合、Git の改行コード変換で照合に失敗しないよう、.gitattributes に *.sql text eol=lf を書いておきます。
起動時に適用する (preload)
tauri.conf.json に書いた DB は、ウィンドウが開く前に開かれてマイグレーションも実行されます。Rust のコマンドから最初に DB を使う場合に便利ですが、失敗するとプラグインの初期化エラー(PluginInitialization と「while executing migration 3: …」を含むメッセージ)で起動時に終了し、画面で知らせることができません。
{
"plugins": {
"sql": {
"preload": ["sqlite:app.db"]
}
}
}
2. フロントエンドから呼び出す (TypeScript)
SQLite では版ごとに 1 つのトランザクションで実行されます。ある版が失敗すると、その版の変更はすべて取り消され、前の版までは適用済みのまま残ります。Database.load() はエラーで終わり、次に起動したときは失敗した版から実行し直します。SQL を直した更新版を配れば、続きから進みます。
注意したいのは、失敗した後に同じ起動中で load() をやり直すと、マイグレーションを実行しないまま古い定義の DB が開けてしまうことです。失敗は画面で知らせ、再試行しません。
import Database from '@tauri-apps/plugin-sql';
type Applied = { version: number; description: string; installed_on: string };
let dbPromise: Promise<Database> | null = null;
// 未適用の版は load() の中で順に実行される
export function openDb(): Promise<Database> {
dbPromise ??= Database.load('sqlite:app.db').catch((e: unknown) => {
// 例: while executing migration 4: error returned from database: (code: 1) duplicate column name: pinned
const p = document.createElement('p');
p.textContent = `データベースを更新できませんでした: ${String(e)}`;
document.body.replaceChildren(p);
throw e; // 失敗した Promise を残し、同じ起動中に load() し直さない
});
return dbPromise;
}
// 適用済みの版(問い合わせ対応やログ用)
export async function appliedVersions(): Promise<Applied[]> {
const db = await openDb();
return db.select<Applied[]>(
'SELECT version, description, installed_on FROM _sqlx_migrations ORDER BY version',
);
}
動作確認
npm run tauri dev で起動して appliedVersions() の結果を console.log すると、次のように出ます(installed_on は UTC)。
[{ version: 1, description: "create_notes", installed_on: "2026-09-12 02:15:07" }, { version: 2, description: "add_pinned", ... }, { version: 3, description: "rebuild_notes", ... }]
失敗の扱いも試しておきます。v4 として既にある列を足す ALTER TABLE notes ADD COLUMN pinned INTEGER; を登録して起動し直すと、画面に「while executing migration 4: error returned from database: (code: 1) duplicate column name: pinned」が出て、記録は v3 までのままです。SQL を直して起動し直すと v4 が適用されます。開発中に最初からやり直すときは、アプリを閉じて設定フォルダーの app.db(あれば -wal と -shm も)を消します。
よくあるエラーと対処法
- 「migration 2 was previously applied but has been modified」: 適用済みの SQL を変えました。開発中なら DB を消して作り直し、配布後なら SQL を元に戻して変更を新しい版で足します。
- 「migration 3 was previously applied but is missing in the resolved migrations」: 版を一覧から消したか、新しい版で開いた DB を古いアプリで開きました。古いアプリへ戻す場合は DB も戻す必要があります。
- マイグレーションが実行されず「no such table: notes」:
add_migrations()とDatabase.load()の文字列が違います(sqlite:app.dbとsqlite:./app.dbなど)。一致しないとエラーも出ずに素通りします。 - 「duplicate column name」「no such column」などで load() が失敗する: その版の SQL の誤りです。その版は取り消されているので、直して起動し直せば続きから進みます。
注意点
- 外部キーで参照されている表を作り直さない: このプラグインの接続では外部キーが有効で、マイグレーションの中から無効にすることもできません。v3 のような作り直しで
DROP TABLEすると、ON DELETE CASCADEなら参照している子の行まで消え、付けていなければ外部キー違反でその版が失敗します。 - 実行中に
execute()で ALTER TABLE しない: 定義の変更はload()時のマイグレーションで行います。実行中に列を変えると、ほかの接続で実行したSELECT *が空の結果を返すことがあります。列名を並べて SELECT する習慣も付けておきます。 - 初期データ: 最初から大量のデータが入った DB を配るなら、DB ファイルを配布物に同梱 してから設定フォルダーへ複製する方法もあります(その DB も同じマイグレーションで作っておきます)。値を埋め込む SQL は プリペアドステートメント で書きます。
