"Look, I ain't in this for your revolution, and I'm not in it for you, Princess. I expect to be well paid. I'm in it for the money." - Han Solo
Smuggle data between SQLite and Cloudflare D1. Fast. Stateless. Questionable life choices.
We use this in production at huttspawn.com. It works. Mostly.
Should you use it? That depends. Do you:
- Read the manual before assembling furniture? Maybe wait for 1.0
- Shoot first? Welcome aboard
There are known issues. We're shaving parsecs, not days.
D1 is SQLite at the edge, but Cloudflare doesn't give you a way to sync your local dev database with production. Smuggler fills that gap:
- Content hashing - Compares actual row data, not just timestamps
- Delta sync - Only moves rows that changed
- Bidirectional - Push to D1, pull from D1, or both
- No state files - Fresh comparison every run, no stale state to haunt you
curl -fsSL https://raw.githubusercontent.com/ezmode-games/smuggler/main/install.sh | bashDetects your platform, downloads the right binary, verifies the checksum, and installs to ~/.local/bin/. Supports Linux x64, macOS x64, and macOS ARM64.
Install a specific version:
curl -fsSL https://raw.githubusercontent.com/ezmode-games/smuggler/main/install.sh | bash -s v0.1.0| Platform | Download |
|---|---|
| Linux x64 | smuggler-linux-x64.tar.gz |
| macOS x64 | smuggler-macos-x64.tar.gz |
| macOS ARM64 | smuggler-macos-arm64.tar.gz |
cargo install --git https://github.com/ezmode-games/smuggler- Copy the example config:
cp config.example.toml config.toml- Add your credentials (don't commit this file, genius):
cloudflare_account_id = "your-account-id"
cloudflare_api_token = "your-api-token"
database_id = "your-d1-database-id"
local_db = ".wrangler/state/v3/d1/miniflare-D1DatabaseObject/xxx.sqlite"- Check if you can reach D1:
smuggler status- See what's different:
smuggler diff- Push your local changes (point of no return):
smuggler pushsmuggler status # Can we phone home?
smuggler diff # What's different?
smuggler push # Local -> D1 (YOLO)
smuggler pull # D1 -> Local (safer YOLO)
-c, --config <FILE> Config file [default: config.toml]
-v, --verbose See what's happening under the hood
--dry-run Coward mode (just kidding, it's smart)
--table <NAME> Sync one table only (validated against schema)
For each table, Smuggler:
- Grabs all primary keys from both databases
- SHA256 hashes each row's content (excluding timestamp columns)
- When content differs, compares timestamps to determine which side is newer
- Sorts rows into buckets:
| Bucket | What it means | Push | Pull |
|---|---|---|---|
local_only |
You added it locally | Insert to D1 | - |
remote_only |
Someone else added it | - | Insert locally |
local_newer |
Your timestamp wins | Update D1 | - |
remote_newer |
Their timestamp wins | - | Update local |
content_differs |
Same timestamp, different data | Configurable | Configurable |
identical |
Exactly the same | Skip | Skip |
Timestamps lie. Clocks drift. Bulk imports set everything to "now". Content hashing catches actual changes regardless of what the timestamps say.
cloudflare_account_id = "abc123"
cloudflare_api_token = "your-token-with-d1-permissions"
database_id = "your-d1-uuid"
local_db = "/path/to/local.sqlite"
[sync]
# Empty = sync all tables except excluded
tables = []
# Things you definitely don't want to sync
exclude_tables = [
"sqlite_sequence",
"_cf_KV",
"__drizzle_migrations",
]
# Optional column for timestamp ordering when content differs
timestamp_column = "updated_at"
# When both sides changed: "local_wins", "remote_wins", "newer_wins"
conflict_resolution = "local_wins"Wrangler hides it at:
.wrangler/state/v3/d1/miniflare-D1DatabaseObject/<hash>.sqlite
The hash is derived from your binding name. If you have multiple databases, may the Force be with you.
Get one from Cloudflare Dashboard:
- D1:Read - for
diff,pull,status - D1:Write - for
push
Pro tip: Create one token with both permissions. Fewer tokens to lose.
Things we don't do:
- Schema sync - Run your migrations separately, we're data movers not DDL runners
- Transactions - Each batch is atomic, but the whole sync isn't. Re-run if interrupted.
- BLOB wizardry - Binary data compared as hex strings. It works but it's not pretty.
- Tables without primary keys - We need something to compare. Add a PK.
Your token is expired or wrong. Make a new one.
Smuggler validates --table input against your database schema before touching any SQL. If the table doesn't exist, you'll get a list of available tables. Run your migrations on both databases first. We don't create tables.
Check that column order and types match. NULL vs empty string will cause hash mismatches.
cargo test # Run the tests
cargo fmt # Format code
cargo clippy --all-targets # Lint (including tests)
RUST_LOG=debug cargo run -- diff # Debug outputPart of the ezmode-games toolchain, built for huttspawn.com:
- More tools coming when we get around to it
See CONTRIBUTING.md. TL;DR:
- Fork it
- Branch it
- Fix it / Build it
- Test it
- PR it
MIT. Do whatever you want. Not our fault if it deletes your production database.
"Never tell me the odds."
