A local, offline dbdiagram.io-style ER-diagram tool for macOS. Paste DBML
on the left, get a live relationship diagram on the right — colored table cards, PK/FK icons,
crow's-foot relationship splines, a Notes toggle, pan/zoom, and drag-to-arrange. Export to
SVG / PNG / Mermaid. Runs as a native .app (Tauri v2, Rust backend + web frontend); the DBML
parser is pure Rust and also usable headless from the CLI.
This is a personal tool, not a git repo. It is unsigned (no Apple Developer ID) — see Gotchas.
# 0. Rust lives in a keg-only rustup — export this in EVERY shell that runs cargo/tauri:
export PATH="/opt/homebrew/opt/rustup/bin:$HOME/.cargo/bin:$PATH"
# 1. Live dev app (native window, hot-reload frontend, REAL Rust parser):
npx tauri dev # run from the repo root
# 2. Build the production .app:
npx tauri build --bundles app # -> src-tauri/target/release/bundle/macos/DBML Diagrams.app
# 3. Run the built app on a schema file:
./bin/dbml open fixtures/care_marketplace.dbml
# 4. Headless DBML -> Mermaid (no GUI; also reads stdin):
./bin/dbml mermaid fixtures/demo.dbml
# See every command:
./bin/dbml help
# 5. Tests:
cargo test --manifest-path parser-rs/Cargo.toml # parser (needs the PATH above)
cargo test --manifest-path src-tauri/Cargo.toml # tauri shell
cd frontend && npm run test # renderer/layout (vitest)| Tool | Version used | Notes |
|---|---|---|
| macOS | — | Apple Silicon (/opt/homebrew) |
| Rust (via rustup) | cargo/rustc 1.97.1 | Installed keg-only via Homebrew. cargo is at /opt/homebrew/opt/rustup/bin/cargo. Not on PATH by default. |
| Node | v24 | |
| npm | 11 |
The one thing that trips you up: cargo is not on your PATH. Before any cargo or npx tauri
command, run:
export PATH="/opt/homebrew/opt/rustup/bin:$HOME/.cargo/bin:$PATH"Node deps are already installed (node_modules/ at root and in frontend/). If you ever need to
reinstall: npm install at the repo root and npm install in frontend/.
db-diagrams/
├── package.json # root: provides the `tauri` CLI (@tauri-apps/cli) → `npx tauri …`
├── bin/
│ ├── dbml-diagrams # sh wrapper: open the .app on a .dbml file
│ └── dbml2mermaid # sh wrapper: headless DBML → Mermaid (file or stdin)
├── parser-rs/ # Rust crate: the DBML parser (the heart of the tool)
│ ├── src/
│ │ ├── model.rs # the Schema data contract (Rust side)
│ │ ├── tokenizer.rs # DBML lexer
│ │ ├── parser.rs # DBML → Schema
│ │ ├── mermaid.rs # Schema → Mermaid erDiagram (single source of truth)
│ │ ├── lib.rs # parse_dbml() + to_mermaid() exports
│ │ └── bin/
│ │ ├── parse.rs # `parse <file>` → canonical JSON on stdout
│ │ └── mermaid.rs # `mermaid <file|stdin>` → Mermaid on stdout
│ └── tests/ # golden.rs, negative.rs, unit.rs
├── src-tauri/ # Tauri v2 desktop shell (Rust)
│ ├── src/lib.rs # #[tauri::command]s: parse_dbml, cli_schema, save_export, to_mermaid
│ ├── tauri.conf.json # window, bundle targets, before-dev/build commands
│ ├── icons/ # app icon set
│ └── target/… # build output (the .app lands here)
├── frontend/ # Vite + vanilla TypeScript + SVG (no framework)
│ ├── index.html
│ └── src/
│ ├── app.ts # app shell: toolbar, editor, collapse/resize, export handlers
│ ├── app.css
│ ├── contract.ts # the Schema data contract (TS side — MUST match model.rs)
│ ├── parse-adapter.ts# picks the fixture vs tauri parser adapter (see below)
│ ├── adapters/ # fixture.ts (browser, canned) + tauri.ts (invoke real parser)
│ ├── render.ts # pure render(container, schema, opts) → SVG
│ ├── theme.ts # colors / card styling
│ ├── layout.ts # deterministic auto-layout (layered on the FK graph)
│ ├── interaction.ts # pan / zoom / drag / fit
│ └── *.test.ts # vitest suites
├── fixtures/ # sample/demo/care_marketplace .dbml + *.expected.json goldens
├── docs/ # design docs (read these before changing the contract!)
│ ├── data-contract.md # ← the Schema JSON seam; the most important doc
│ ├── dbml-grammar.md # what DBML we support / reject
│ ├── design.md
│ └── layout-and-render.md
└── dist/ # a built .dmg (may be stale — see Packaging)
The whole app is built around one JSON contract — the Schema object — that sits between the
Rust parser and the TypeScript renderer. The renderer never sees DBML text; it only consumes Schema.
DBML text (editor)
│ invoke('parse_dbml', { text })
▼
parser_rs::parse_dbml() ──► ParseResult { ok, schema: Schema, error } (Rust)
│ │ (canonical JSON over Tauri IPC)
▼ ▼
error bar (on !ok) render(container, schema, opts) ──► SVG (TS, pure)
│
interaction: pan / zoom / drag / fit
- The contract is defined twice and must stay in sync:
parser-rs/src/model.rs(Rust) andfrontend/src/contract.ts(TS). If you change the shape, change both and update thefixtures/*.expected.jsongoldens.docs/data-contract.mdis the authoritative spec (key order, null rules,isFkderivation, from-note FK extraction, dedup). - Mermaid has a single generator:
parser_rs::to_mermaid(&Schema). The app's Export Mermaid button calls theto_mermaidTauri command (parse + generate); thebin/dbml2mermaidCLI calls the same function. One source of truth → GUI and CLI never diverge. - CLI file loading: the app reads the first non-flag argv as a file path and exposes it via the
cli_schemacommand;app.tsseeds the editor from it on startup. - Export:
save_export(name, kind, data)writes to~/Downloads.kind=svg/mmd/mermaid→ UTF-8;png→ base64-decoded.
frontend/src/parse-adapter.ts selects the parser at build time via the VITE_ADAPTER env var:
| How you run it | VITE_ADAPTER |
Adapter | Parses your DBML? |
|---|---|---|---|
npx tauri dev / packaged .app |
tauri |
calls real Rust parse_dbml |
Yes |
cd frontend && npm run dev |
(unset) | fixture (canned schema) | No — ignores editor text |
So plain npm run dev is only useful for iterating on render/layout/interaction/CSS against a
fixed schema. Anything that needs real parsing or Mermaid export (which is Rust-only) requires
npx tauri dev or the built app.
export PATH="/opt/homebrew/opt/rustup/bin:$HOME/.cargo/bin:$PATH"
npx tauri dev # from repo rootThis runs the config's beforeDevCommand (npm --prefix frontend run dev:tauri, i.e. Vite on
http://localhost:5173 with VITE_ADAPTER=tauri), then opens the native window pointed at it.
Editing frontend/src/** hot-reloads instantly. Editing Rust (parser-rs or src-tauri)
triggers a recompile + relaunch.
cd frontend && npm run dev # http://localhost:5173 (fixture adapter — see table above)cd frontend
npm run typecheck # tsc --noEmit
npm run build # tsc && vite build → frontend/dist
npm run test # vitest runexport PATH="/opt/homebrew/opt/rustup/bin:$HOME/.cargo/bin:$PATH"
npx tauri build --bundles app
# → "src-tauri/target/release/bundle/macos/DBML Diagrams.app"--bundles app builds just the .app. Do not rely on a plain npx tauri build for
distribution: it also runs the dmg bundle target, whose bundle_dmg.sh needs Finder/AppleScript
automation and fails in a headless/agent shell (the .app is still produced fine).
npx tauri build runs beforeBuildCommand (npm --prefix frontend run build:tauri) automatically,
so the frontend is rebuilt and embedded into the binary — you don't build the frontend separately.
Tauri's styled DMG step is unreliable headless, so build a plain drag-to-Applications DMG with
hdiutil:
APP="src-tauri/target/release/bundle/macos/DBML Diagrams.app"
STAGE="$(mktemp -d)/DBML Diagrams"
mkdir -p "$STAGE" && cp -R "$APP" "$STAGE/" && ln -s /Applications "$STAGE/Applications"
hdiutil create -volname "DBML Diagrams" -srcfolder "$STAGE" -ov -format UDZO \
dist/DBML-Diagrams-0.1.0.dmg
dist/DBML-Diagrams-0.1.0.dmgin the repo may be stale (pre-Mermaid/collapse). Rebuild it with the above only when you actually need to hand the app to someone; day-to-day you run the.appdirectly.
One self-describing entry point. It self-locates (resolves the repo from its own path, so it works
from any directory and doesn't hard-code an absolute path) and sets the rustup PATH itself. Run
./bin/dbml help to see the whole interface:
dbml — local DBML ER-diagram tool CLI
USAGE:
dbml <command> [file.dbml]
COMMANDS:
open <file> Open the desktop app, seeded with the given DBML schema
(if the app is already running, opens the file in a new tab)
mermaid [file] Print a Mermaid erDiagram for the schema (file or stdin)
json [file] Print the parsed schema as canonical JSON (file or stdin)
build Build the parser CLIs and the desktop .app
help Show this help
mermaid/json accept a DBML file arg, or read DBML on stdin when given none.
./bin/dbml open fixtures/care_marketplace.dbml # launch the app on a schema
./bin/dbml mermaid fixtures/demo.dbml # DBML → Mermaid (stdout)
cat schema.dbml | ./bin/dbml mermaid # …or from stdin
./bin/dbml json fixtures/demo.dbml # DBML → canonical Schema JSON
./bin/dbml build # build parser bins + the .appDetails:
openexecs the app binary directly (not macOSopen --args, which strips argv) so the file path reaches the process. Needs the app built (./bin/dbml build, ornpx tauri build --bundles app); otherwise it printsApp not built. Run: dbml build. The app is single-instance: opening a second file while it's running forwards the path to the existing window, which opens it as a new tab (named after the file) rather than launching a second window. You can also add tabs in-app with the + button, or Open a file into a new tab.mermaid/jsonauto-build the (fast) parser bins on first use if missing — build output goes to stderr so it never pollutes captured stdout.mermaidexits 0 on success; on a parse error it printserror (line L:C): messageto stderr and exits 1; unreadable file exits 2.- Unknown command → usage on stderr, exit 2.
Tip: put bin/ on your PATH to call it from anywhere:
export PATH="/Users/abtinghods/Ork/tools/db-diagrams/bin:$PATH"
dbml mermaid schema.dbmlThe old commands still exist as thin aliases: bin/dbml-diagrams → dbml open,
bin/dbml2mermaid → dbml mermaid.
The .dbml file is the integration surface — an LLM edits DBML text (LLMs are great at that),
then drives the tool through dbml:
dbml open schema.dbml— view it in the app.dbml mermaid schema.dbml— get a Mermaid diagram (stdout; pipe-friendly).dbml json schema.dbml— read the structured schema back ({"ok":true,"schema":…}) to verify what it built, or catch a parse error ({"ok":false,"error":{line,column,message}}).
No live-app API or IPC needed — it's a plain file round-trip an agent can script.
export PATH="/opt/homebrew/opt/rustup/bin:$HOME/.cargo/bin:$PATH"
cargo test --manifest-path parser-rs/Cargo.toml # ~23 tests: golden, negative, unit, mermaid
cargo test --manifest-path src-tauri/Cargo.toml # ~3 tests: commands + save_export
cd frontend && npm run test # ~19 tests: contract, layout, render (vitest)Golden tests parse fixtures/*.dbml and compare against fixtures/*.expected.json. When you change
parser behavior, regenerate/adjust the expected JSON and keep Rust model.rs ⇄ TS contract.ts in
sync.
| I want to… | Edit |
|---|---|
| Support new DBML syntax | parser-rs/src/tokenizer.rs, parser.rs; add a golden fixture; update docs/dbml-grammar.md |
Change the Schema shape |
parser-rs/src/model.rs and frontend/src/contract.ts and the fixtures + docs/data-contract.md |
| Change table colors / card look | frontend/src/theme.ts, render.ts |
| Change auto-layout | frontend/src/layout.ts |
| Change pan/zoom/drag | frontend/src/interaction.ts |
| Change the Mermaid output | parser-rs/src/mermaid.rs (+ its #[cfg(test)] tests) |
| Add a toolbar button / wire a feature | frontend/index.html, frontend/src/app.ts, app.css |
| Add a backend command | add #[tauri::command] in src-tauri/src/lib.rs, register it in generate_handler![…], call it from app.ts via the tauriInvoke helper |
cargo: command not found/ build can't find Rust → you forgotexport PATH="/opt/homebrew/opt/rustup/bin:$HOME/.cargo/bin:$PATH".npx tauri buildfails at the DMG step → expected headless; usenpx tauri build --bundles app. The.appis built regardless.- Plain
npm run dev"ignores my DBML" → that's the fixture adapter. Usenpx tauri dev. - Unsigned app / Gatekeeper ("can't be opened because Apple cannot check it") → there is no Apple
Developer ID for this app (intentionally). Clear quarantine once:
xattr -dr com.apple.quarantine "src-tauri/target/release/bundle/macos/DBML Diagrams.app". - Table drag positions reset on app restart. Editor width and collapsed state persist
(localStorage:
dbml.editorWidth,dbml.editorCollapsed); table positions are in-session only. - Known parser limits:
indexes { }andEnum/Project/sticky-Noteblocks are parsed but not rendered; composite/multi-column FKs andschema.table-qualified names are not supported. Seedocs/data-contract.md§5 anddocs/dbml-grammar.md. bin/scripts self-locate (they resolve the repo from their own path), so you can move the repo freely — but the built.appand parser bins live under the repo, so after a move rundbml build(ornpx tauri build --bundles app) to regenerate them.