| Crates.io | hb-d1c |
| lib.rs | hb-d1c |
| version | 0.1.1 |
| created_at | 2025-11-19 00:43:28.239334+00 |
| updated_at | 2025-12-03 18:58:27.067935+00 |
| description | Type-safe SQL generator for Cloudflare D1 + Rust |
| homepage | |
| repository | https://github.com/saavylab/hb |
| max_upload_size | |
| id | 1939187 |
| size | 118,254 |
Type-safe SQL queries for Cloudflare D1 + Rust Workers
d1c generates compile-time checked Rust functions from your SQL queries. Think sqlc for Go, but designed for Cloudflare's edge platform.
// Write SQL with named parameters
-- name: ListMonitors :many
SELECT id, name, enabled FROM monitors WHERE org_id = :org_id;
// Get type-safe Rust functions
let monitors = d1c::queries::list_monitors(&d1, org_id).await?;
No positional parameter bugs. No manual JSON parsing. No runtime type errors.
Status: Early development. Core features work, but expect evolution based on real-world feedback.
Raw D1 queries are painful:
// Positional parameters are error-prone
let result = d1.prepare("SELECT * FROM users WHERE org_id = ?1 AND active = ?2")
.bind(&[org_id.into(), active.into()])? // did you get the order right?
.all()
.await?;
// Manual JSON parsing is boilerplate-heavy
for row in result.results {
let id = row.get("id").ok_or("missing id")?; // hope you spelled it right
let name = row.get("name").ok_or("missing name")?;
// repeat for every field...
}
With d1c:
let users = queries::list_active_users(&d1, org_id).await?;
// That's it. Compile-time checked, zero boilerplate.
cargo install hb-d1c
cd your-worker-project
d1c init
This reads your wrangler.toml, creates d1c.toml, and adds an example query.
-- db/queries/users.sql
-- name: GetUser :one
SELECT id, email, active FROM users WHERE id = :id;
-- name: ListActiveUsers :many
SELECT id, email FROM users WHERE org_id = :org_id AND active = true;
-- name: CreateUser :one
INSERT INTO users (id, email, org_id, active)
VALUES (:id, :email, :org_id, :active)
RETURNING *;
d1c generate
This creates src/d1c/queries.rs with type-safe functions for each query.
use crate::d1c::queries;
#[worker::event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
let d1 = env.d1("DB")?;
let user = queries::get_user(&d1, "user_123").await?;
let users = queries::list_active_users(&d1, "org_456").await?;
Response::ok("done")
}
👉 See GETTING_STARTED.md for a complete tutorial and QUERY_FORMAT.md for full syntax reference.
d1c uses your Wrangler migrations as the schema source:
1. Parse db/migrations/*.sql (your Wrangler migration files)
2. Replay them into a local SQLite database
3. Introspect schema to understand types
4. Read db/queries/*.sql (your query files)
5. Generate type-safe Rust functions
Key insight: D1 is SQLite, so local SQLite introspection gives us perfect type information.
Specify what your query returns with the :cardinality annotation:
| Cardinality | Return Type | Use Case |
|---|---|---|
:one |
Result<Option<Row>> |
Single row (or none) |
:many |
Result<Vec<Row>> |
Multiple rows |
:exec |
Result<()> |
INSERT/UPDATE/DELETE without RETURNING |
:scalar |
Result<Option<T>> |
Single primitive value (COUNT, SUM, etc.) |
Use :param_name in queries:
-- name: FindUser :one
SELECT * FROM users WHERE email = :email AND active = :active;
Generated function signature:
pub async fn find_user(
d1: &D1Database,
email: &str,
active: bool,
) -> worker::Result<Option<FindUserRow>>
Override default behavior with special comments:
-- name: GetUserBalance :one
-- params: user_id UserId, currency String
SELECT balance FROM accounts WHERE user_id = :user_id AND currency = :currency;
Available headers:
-- params: name Type, ... – Override inferred parameter types (useful for newtypes)-- instrument: skip(field, ...) – Exclude parameters from tracing spans (see Observability section)👉 See QUERY_FORMAT.md for complete syntax reference, examples, and edge cases.
| Command | Description |
|---|---|
d1c init |
Create d1c.toml config |
d1c generate (or gen) |
Generate Rust code from queries |
d1c watch |
Auto-regenerate on file changes |
d1c dump-schema |
Export current schema to stdout |
?1, ?2 mistakesd1c encourages splitting queries across multiple .sql files by domain:
db/queries/
├── users.sql → src/d1c/queries/users.rs
├── monitors.sql → src/d1c/queries/monitors.rs
└── orgs.sql → src/d1c/queries/orgs.rs
Each file becomes a Rust submodule. Use them like:
use crate::d1c::queries::{users, monitors};
let user = users::get_user(&d1, user_id).await?;
let monitors = monitors::list_by_org(&d1, org_id).await?;
d1c can add #[tracing::instrument] to generated functions so database spans flow into whatever telemetry backend you use (Cloudflare Workers Observability picks them up automatically when traces/logs are enabled).
Enable during setup:
d1c init
# → Enable tracing? [y/N] y
Or add to d1c.toml:
instrument_by_default = true
What you get:
d1c.list_users, d1c.get_monitor, etc.)Hide sensitive parameters:
-- name: LoginUser :one
-- instrument: skip(password_hash)
SELECT * FROM users WHERE email = :email AND password_hash = :password_hash;
This generates:
#[tracing::instrument(name = "d1c.login_user", skip(d1, password_hash))]
pub async fn login_user(d1: &D1Database, email: &str, password_hash: &str) { ... }
d1c.toml in your project root:
migrations_dir = "db/migrations" # Your Wrangler migrations
queries_dir = "db/queries" # Your query files
codegen_dir = "src/d1c" # Where to write generated code
# Optional
module_name = "queries" # Generated module name (default: "queries")
instrument_by_default = false # Add tracing spans (default: false)
Found a bug? Query that doesn't parse? We'd love to hear about it:
Especially interested in feedback from production D1 users.
MIT
Inspired by sqlc. Built for teams running Rust Workers who want type safety without the weight of traditional ORMs.