個人で開発している macOS 向けの DB 設計ツール「Joinery」を公開しました。無料で、すべての機能を使えます。
この記事の前半では使い方を、後半ではデスクトップ側を Tauri 2 + Rust でどう作ったかを紹介します。後半では、実 DB への接続を読み取り専用に保つ仕組み、SSH トンネル、ファイルの原子的な保存などを扱います。
Joinery でできること
スキーマを 1 つの DBML ファイルに書くと、ER 図・各方言の DDL・テーブル定義書・ALTER 文がそこから作られます。図の上で編集しても、書き換わるのは DBML の該当行だけです。
| 分類 | 内容 |
|---|---|
| 編集 | DBML エディタ(補完・Lint 表示)、図からのテーブル・列・Ref の作成、インスペクタ |
| 図 | crow's foot 記法、TableGroup の色分け、自動配置、Auto Group、Focus モード |
| 取り込み | SQL ファイル(PostgreSQL / MySQL / SQL Server / SQLite)、実 DB(PostgreSQL / MySQL / SQLite) |
| 出力 | DDL(4 方言)、SVG / PNG、設計書(HTML・Excel) |
| 差分 | ファイル・git HEAD・実 DB との差分から ALTER 文を生成 |
| AI(任意) | アプリ内アシスタント。変更は差分を見て承認してから反映 |
- ファイルは dbdiagram.io と互換の DBML です。独自構文はないので、既存の
.dbmlをそのまま開けます。 - AI を有効にしない限り、スキーマを外部に送りません。DB のパスワードや API キーは macOS のキーチェーンに保存します。
インストール
joinerydb.com から .dmg をダウンロードして開き、Joinery をアプリケーションフォルダへドラッグします。Developer ID で署名し、Apple の公証を受けているので、そのまま起動できます。
- 対応: Apple Silicon 搭載の Mac(macOS 11 以降)
- アカウント登録: 不要
使い方
1. DBML でテーブルを書く
左のエディタに DBML を書くと、右の図にすぐ反映されます。
Table customers {
id bigint [pk, increment]
name varchar(200) [not null, note: '会社名']
email varchar(255) [not null, unique]
created_at timestamptz [not null, default: `now()`]
}
Table orders {
id bigint [pk, increment]
customer_id bigint [not null, ref: > customers.id]
status order_status [not null, default: 'draft']
total_amount numeric(12,2) [not null]
ordered_at timestamptz [not null]
}
Enum order_status {
draft
confirmed
shipped
}
構文エラーがある間は、最後に正しく解析できた図を表示したまま「構文エラー:最後に有効な図を表示中」と出ます。書いている途中で図が消えることはありません。
2. 既存の DB や DDL から取り込む
ゼロから書かなくても、既存のスキーマから始められます。Cmd+K でコマンドパレットを開き、次のどちらかを選びます。
- Import SQL ファイル…: PostgreSQL / MySQL / SQL Server / SQLite の DDL を取り込みます
- Import データベースから…: PostgreSQL / MySQL / SQLite に接続し、スキーマを取り込みます
実 DB への接続は読み取り専用です。接続の経路は、直接・SSH トンネル・~/.ssh/config のホスト・SOCKS5・HTTP CONNECT から選べます。踏み台の先にある開発用 DB にもそのままつなげます。
3. 図とインスペクタで編集する
図でテーブルを選ぶと、右のインスペクタで列・型・NOT NULL・インデックス・グループ・ヘッダの色を編集できます。
図で編集しても、DBML は該当する行だけが書き換わります。コメントや書式はそのまま残ります。テーブル名や列名を変えると、参照している箇所もすべて追従します。
テーブルの配置は foo.joinery.json という別ファイルに、1 テーブル 1 行・8px グリッドに丸めた座標で保存します。テーブルをドラッグしても、Git の差分は動かしたテーブルの行だけです。
{ "version": 1, "tables": {"public.customers": [64, 128], "public.orders": [384, 128]}, "views": {}, "lint": {} }
テーブルが増えてきたら、Auto Group Tables… で外部キーと名前の接頭辞からグループを提案させたり、Focus モードで選んだテーブルから 1〜2 ホップ先だけを表示したりできます。
4. Lint で設計の問題を見つける
書いている間、Lint が設計の問題を下の Problems パネルに出します。
| コード | 内容 |
|---|---|
| JNR001 | 主キーがない |
| JNR002 | 外部キーと参照先の型が一致しない |
| JNR003 | 外部キーの列にインデックスがない(MySQL は対象外) |
| JNR004 | 参照先が一意でない |
| JNR005 | ほかのインデックスの先頭部分に含まれる冗長なインデックス |
このほか、予約語(JNR006)、命名規則(JNR007、既定はオフ)、enum の既定値が値の一覧にない(JNR008)も検査します。
5. DDL を出力する
右ペインの SQL タブで方言を選ぶと、その方言の DDL が表示されます。同じ DBML から、方言ごとに書き方の違う部分を出し分けます。
| PostgreSQL | MySQL | SQLite | SQL Server | |
|---|---|---|---|---|
| 自動採番 | generated by default as identity |
AUTO_INCREMENT |
INTEGER PRIMARY KEY AUTOINCREMENT |
IDENTITY(1,1) |
| enum | CREATE TYPE |
列の ENUM(...)
|
CHECK (... IN (...)) |
CHECK (... IN (...)) |
6. テーブル定義書を作る
設計書 タブで、HTML(1 ファイルで開ける)と Excel のテーブル定義書を出力できます。列の型・NULL 可否・既定値・インデックス・外部キー・note をまとめて載せます。表紙と改訂履歴も画面から編集でき、保存のたびに自動で書き出す設定もあります。
7. 差分から ALTER 文を作る
差分 タブで比較元(ファイル・git HEAD・実 DB)を選ぶと、比較元からエディタの内容への ALTER 文を生成します。
- 列名の変更は、DROP と ADD のどちらとして扱うかを推測で決めません。候補として出し、確認してから対応付けます。
- DROP や型の縮小には警告を付けます。
生成した ALTER は、書き込みを許可した接続であれば、開発用 DB にそのまま適用することもできます(後半で詳しく書きます)。
8. データを確認する
データ タブでは、テーブルの中身のプレビューと、読み取り専用の SQL 実行ができます。結果は CSV で保存できます。
9. AI アシスタント(任意)
Cmd+I で開くアシスタントに「レビューのテーブルを追加して」のように頼むと、DBML の変更案が差分で表示されます。承認したときだけ反映されます。プロバイダは Anthropic API・OpenAI API・ローカルの Claude Code から選べ、API キーは各自のものを使います。
Tauri 2 + Rust での実装
ここからは作り方の話です。
構成
画面は React 19 + Vite、デスクトップのシェルは Tauri 2 です。役割は次のように分けています。
| 層 | 主な中身 |
|---|---|
packages/core(TypeScript) |
手書きの DBML パーサ(エラー回復付き)、編集操作、Lint、SQL 入出力、差分、設計書生成 |
apps/desktop(React) |
エディタ(CodeMirror 6)、図(React Flow + elkjs)、各タブ |
src-tauri(Rust、約 1 万行) |
ファイルの保存と監視、実 DB への接続・取り込み・クエリ、SSH トンネルとプロキシ、ALTER の適用、キーチェーン |
DBML の解析や差分計算は、ブラウザでも動くように TypeScript の純粋な関数にしています。一方、OS や DB に触れる部分はすべて Rust 側に置きました。主なクレートは次のとおりです。
| 用途 | クレート |
|---|---|
| DB 接続 |
sqlx 0.8(postgres / mysql / sqlite、runtime-tokio、tls-rustls) |
| SQL の構文解析 |
sqlparser 0.63 |
| ファイル監視 |
notify 8 |
| 秘密情報 |
keyring 3(macOS ではキーチェーン) |
| 原子的な書き込み | tempfile |
フロントエンドからファイルや DB を直接触らせず、Rust のコマンドだけを公開しています(tauri-plugin-fs も使っていません)。危ない操作の入口が Rust の関数に限られるので、検査や制限を 1 か所に書けます。
実 DB への接続を「読み取り専用」に保つ
DB 設計ツールが本番に近い DB につながる以上、誤って書き込まないことが一番大事です。データタブで任意の SQL を実行できるため、読み取り専用を 3 段で守っています。
1 段目: SQL を構文解析し、SELECT 1 文だけを通す
sqlparser で AST にし、文が 1 つだけで、しかも Statement::Query(SELECT / WITH)であることを確かめます。SELECT ... INTO は新しいテーブルを作れてしまうので、トークンの段階で弾きます。
fn validate(sql: &str, connection: &DbConnection) -> Result<(), Error> {
// ...
if tokens
.iter()
.any(|token| matches!(token, Token::Word(word) if word.keyword == Keyword::INTO))
{
return Err(Error::Db("SELECT INTO is not read-only".into()));
}
let statements = Parser::new(dialect)
.with_tokens(tokens)
.parse_statements()
.map_err(|_| Error::Db("Invalid or unsupported SQL".into()))?;
if statements.len() != 1 {
return Err(Error::Db("Exactly one SQL statement is required".into()));
}
// Session/transaction commands must not disable the read-only boundary.
if !matches!(statements[0], Statement::Query(_)) {
return Err(Error::Db(
"Only SELECT queries (including WITH) are supported".into(),
));
}
Ok(())
}
2 段目: セッションを読み取り専用で開く
構文解析だけでは、副作用のある関数を SELECT の中で呼ばれる場合を防げません。そこで、接続そのものを DB 側で読み取り専用にします。
- PostgreSQL:
SET default_transaction_read_only = on - MySQL:
SET SESSION TRANSACTION READ ONLY - SQLite: 読み取り専用で開き、
PRAGMA query_only = ON
3 段目: クエリごとに READ ONLY トランザクションで実行する
match connection {
DbConnection::Postgres(c) => {
c.execute("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY").await?;
}
DbConnection::Mysql(c) => {
c.execute("SET SESSION MAX_EXECUTION_TIME=11000").await?;
c.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ").await?;
c.execute("START TRANSACTION READ ONLY").await?;
}
// ...
}
1 段目で SET TRANSACTION READ WRITE のようなセッション操作を通さないので、2 段目・3 段目の制限をユーザーの SQL で外されることもありません。
10 秒のタイムアウトと中止を、DB ごとに確実に効かせる
重いクエリでアプリが固まらないよう、どのクエリも 10 秒で打ち切ります。待つ処理は tokio::select! で「中止ボタン」と「期限」を競わせます。
let result = tokio::select! {
biased;
_ = cancel.notify.notified() => Err(Error::Db("Query cancelled".into())),
result = tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), operation) => {
// A ready result can win the timeout race, notably MySQL SLEEP returning 1.
if Instant::now() >= deadline {
Err(Error::Timeout("Query timed out after 10 seconds".into()))
} else {
result.unwrap_or_else(|_| Err(Error::Timeout("Query timed out after 10 seconds".into())))
}
}
};
クライアント側で待つのをやめても、サーバー側でクエリが動き続けては意味がありません。そこで DB ごとに止め方を変えています。
- PostgreSQL・MySQL: 打ち切ったら、その接続は再利用せずに捨てます。MySQL ではサーバー側にも
MAX_EXECUTION_TIMEを設定しています。 - SQLite: 同じプロセス内で動くので、
set_progress_handlerで 1000 命令ごとに期限と中止フラグを確認し、超えていたら止めます。
SSH トンネルは OS の ssh を子プロセスで使う
Rust には SSH を実装したクレートもありますが、あえて OS の ssh コマンドを子プロセスとして起動しています。~/.ssh/config の ProxyJump・IdentityFile・Include、ssh-agent、1Password などの SSH エージェントを、ユーザーの設定どおりに使えるからです。
let mut args = vec![
"-N".into(),
"-L".into(),
format!("{}:{target}:{db_port}", socket.display()),
];
for option in [
"StreamLocalBindMask=0177",
"StreamLocalBindUnlink=yes",
"ExitOnForwardFailure=yes",
"ServerAliveInterval=15",
"StrictHostKeyChecking=yes",
"UpdateHostKeys=no",
"ControlMaster=no",
"ControlPath=none",
// ...
] {
args.extend(["-o".into(), option.into()]);
}
安全のために、次の点に気を付けています。
- ローカル側は TCP ポートを開かない: 転送先は、接続ごとに作る権限 0700 の一時ディレクトリ内の Unix ソケットです。同じ Mac のほかのユーザーやプロセスが、トンネルを通って踏み台の先へ入れないようにするためです。sqlx は、PostgreSQL ならソケットのディレクトリ、MySQL ならソケットのパスに接続します。
-
ホスト鍵を自動で受け入れない:
StrictHostKeyChecking=yesです。未知のホストでは指紋を表示し、承認されたときだけ known_hosts に追加します。鍵が一致しないときは接続を拒否します。 -
シェルを通さない: ユーザーの入力は引数の配列で渡します。
-で始まるホスト名・ユーザー名は拒否します。 - 子プロセスを残さない: プロセスグループで管理し、接続を閉じたときやアプリの終了時に必ず止めます。
SOCKS5 と HTTP CONNECT は、Rust で中継を実装しています。
DBML と配置ファイルを原子的に保存し、外部の変更と区別する
保存では、.dbml と .joinery.json の 2 ファイルを書きます。途中で失敗しても壊れたファイルが残らないよう、両方を一時ファイルに書いて fsync してから、それぞれ rename で置き換えます。
fn prepare_write(path: &Path, contents: &[u8]) -> Result<NamedTempFile, Error> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let mut temp = NamedTempFile::new_in(parent)?;
temp.write_all(contents)?;
temp.as_file().sync_all()?;
Ok(temp)
}
pub fn save_project(path: String, dbml: String, sidecar: String) -> Result<SavedProject, Error> {
let dbml_path = Path::new(&path);
let sidecar_path = dbml_path.with_extension("joinery.json");
let dbml_temp = prepare_write(dbml_path, dbml.as_bytes())?;
let sidecar_temp = prepare_write(&sidecar_path, sidecar.as_bytes())?;
// Both writes are staged before replacing either file; each rename is atomic.
dbml_temp.persist(dbml_path).map_err(|e| Error::from(e.error))?;
sidecar_temp.persist(&sidecar_path).map_err(|e| Error::from(e.error))?;
// ...
}
ファイルは notify で監視し、Git の checkout やほかのエディタ、AI エージェントによる変更を画面に反映します。このとき問題になるのが、アプリ自身の保存を外部の変更と取り違えることです。そこで、ファイルの更新日時・サイズ・内容のハッシュを組にした「スタンプ」を記録しています。保存とスタンプの更新を同じロックの中で行うので、遅れて届いた監視イベントがアプリ自身の書き込みを外部の変更と誤認することはありません。
ALTER を開発用 DB に適用する
差分から作った ALTER を DB に適用する機能は、誤操作が一番怖い部分です。次の順に確認します。
- 接続設定で「書き込みを許可」していなければ拒否する(既定は不可)
- 実 DB を取り込み直し、正規化したスキーマのハッシュを、差分を作ったときのものと比べる。違えば、その間に DB が変わったとみなして適用しない
- PostgreSQL・SQLite は 1 つのトランザクションで実行し、失敗したらすべてロールバックする。MySQL は DDL が自動でコミットされるので 1 文ずつ実行し、どこまで適用したかを返す
- 適用後に取り込み直し、目標のスキーマとの差分が空であることを確かめる
本番として登録した接続では、DB 名を手で入力しないと実行できません。適用の記録は、日時・接続名・SQL のハッシュ・結果だけを監査ログに残します。パスワードと SQL の本文は書きません。
テスト
- Rust の単体テストは
cargo testで実行します。ssh の引数の組み立て、stderr からのエラー分類、SOCKS5 / HTTP CONNECT のハンドシェイクなどを含みます。 - Docker で PostgreSQL 17 と MySQL 8.4 を起動し、往復テストを行います。DBML → DDL → 実 DB に流す → 取り込む、とたどった結果が元の DBML と差分なしになることを確かめます。
- TypeScript と Rust の境界(IPC)の型は共通の fixture を使い、両方のテストで形が一致することを確かめています。
動作環境とリンク
- 動作環境: Apple Silicon 搭載の Mac(macOS 11 以降)
- ダウンロード: https://joinerydb.com/
- DB 設計の進め方や ER 図の書き方: DB 設計ガイド
不具合や要望があれば、この記事のコメントで教えてください。








