Quickstart
From an empty directory to a running, type-safe database server with a typed TypeScript client — the whole init → generate → build → serve loop.
This is the whole init → generate → build → serve loop: from an empty directory to a
running, type-safe database server with a typed TypeScript client. Every command and output
below is from a real run against the published crates.
A generator, not a runtime ORM
You write a declarative .forge schema; ForgeDB transpiles it into tailored Rust database
code, a REST API, and a TypeScript SDK. Your schema is a compile-time input to
generation, never a runtime input to a generic engine. Read the honest scope in
what pre-1.0 is (and isn't).
1. Install#
Install the forgedb CLI for your ecosystem — or use the universal shell installer. Every
channel gives you the same binary; installation lists them all.
npm install -g @hoodiecollin/forgedb # or: bun add -g @hoodiecollin/forgedbuv tool install hoodiecollin-forgedb # or: pip install hoodiecollin-forgedbcargo install forgedb# no Go-native CLI channel — use the universal shell installer (macOS/Linux):
curl -fsSL https://get.forgedb.dev/install.sh | shVerify:
forgedb --version # forgedb 0.2.02. Scaffold a project#
forgedb init myblog --template blog
cd myblog--template accepts blog, ecommerce, todo, or blank (the default). init writes:
myblog/
schema.forge # your schema (the single source of truth)
forgedb.toml # project + generate targets + runtime/storage config
generated/ # generated code — reviewed and committed
data/ # this app's data directory
Dockerfile # installs the pinned CLI and runs it
.dockerignore
docker-compose.yml
deploy/ # systemd unit + env file
.gitignore
README.mdThere is no Cargo.toml and no src/main.rs: ForgeDB compiles the generated Rust in its
own build cache, and the env-driven axum server is one of the artifacts
forgedb build produces and reports the path of.
The blog template's schema.forge:
User {
id: +uuid
username: ^&string
email: ^&string @email
password_hash: string
created_at: +timestamp
posts: [Post]
}
Post {
id: +uuid
title: string
slug: ^&string
content: string
published: bool
published_at: timestamp?
created_at: +timestamp
updated_at: +timestamp
author: *User
tags: [Tag]
}
Tag {
id: +uuid
name: ^&string
posts: [Post]
}The modifiers: + auto-generate (uuid/timestamp), & unique, ^ index, ? nullable,
*User a required foreign key, [Post] a one-to-many, [..]/[..] a many-to-many. See
the schema language for the full grammar.
3. Generate code#
The database core and REST API are the same whichever client language you use:
forgedb generate rust # → generated/database.rs
forgedb generate api # → generated/api.rs (+ package.json, tsconfig.json)Then generate the typed client SDK for your ecosystem:
forgedb generate node --sdk # → generated/types.ts (bun --sdk is equivalent)forgedb generate python --sdk # → generated/python-sdk/forgedb_client.pyforgedb generate rust --sdk # → generated/rust-sdk/ (a reqwest client crate)forgedb generate go --sdk # → generated/go-sdk/client.goOr forgedb generate all for everything at once (adds the OpenAPI spec). Output goes to
./generated/ by default (--output to change it). Every generator tailors its code to
your specific models; nothing reads the schema at run time.
The Rust generator emits one database.rs per schema: typed structs, columnar storage,
indexes, relation traversal, validation, and a crash-safe write path. All of it is produced
at compile time against your models, so there is no generic engine reflecting over the
schema while the app runs. That is the invariant the project is built on: the schema feeds
generation, not a runtime query engine.
4. Build#
forgedb buildForgeDB compiles the generated code in its own build cache — a cargo workspace under
~/.forgedb/projects/<project>/ — and prints where each artifact landed. Nothing is
compiled inside your project, which contains no Cargo.toml.
The generated code links only the small, schema-agnostic substrate crates
(forgedb-storage, forgedb-wal, forgedb-types, …), resolved from crates.io. See the
substrate version matrix in installation, and
forgedb build for --plan, --report and --print-artifact.
5. Run the server#
The generated server is an axum binary configured entirely from the environment. Ask ForgeDB where it is rather than composing the path:
SERVER="$(forgedb build --print-artifact server)"
FORGEDB_PORT=3000 FORGEDB_DATA="$PWD/data" "$SERVER"
# INFO myblog: ForgeDB serving tenant=None data_root=/…/myblog/data addr=127.0.0.1:3000Pass an absolute FORGEDB_DATA
The default data root is relative, and the server refuses to open a database inside
ForgeDB's build cache — that directory holds derived artifacts only and may be deleted at
any time. If you cd into the cache to poke around, pass an absolute FORGEDB_DATA or
run from your project directory.
Key environment variables:
| Var | Default | Purpose |
|---|---|---|
FORGEDB_HOST | 127.0.0.1 | bind host (0.0.0.0 in containers) |
FORGEDB_PORT | 3000 | bind port |
FORGEDB_DATA | data | data directory (per-tenant root) |
FORGEDB_TENANT | (unset) | tenant this process serves |
FORGEDB_LOG_FORMAT | (text) | json for machine-parseable log lines |
The server also exposes operational routes that need no auth:
curl localhost:3000/health # {"status":"ok"} — liveness (never touches the DB)
curl localhost:3000/ready # {"status":"ready"} — acquires a read lock
curl localhost:3000/metrics # {"model_count":3,"rows_per_model":{"Post":0,"Tag":0,"User":0},"total_rows":0}The three routes map to standard load-balancer and Kubernetes probes. /health is
liveness: it returns ok without opening the database, so a live-but-busy process still
reports healthy. /ready is readiness: it acquires a read lock, so it reports ready only
once the data directory is actually openable. /metrics returns per-model row counts for
scraping. None require auth because none expose row data, only status and counts.
6. Use the REST API#
Each model gets a REST resource under /api/<model>:
# Create — the server fills +uuid/+timestamp fields; returns the new id (201)
curl -X POST localhost:3000/api/user -H 'content-type: application/json' -d '{
"username":"ada","email":"ada@example.com","password_hash":"x","posts":null
}'
# → {"id":"<server-generated uuid>"}
# List — paginated envelope
curl localhost:3000/api/user
# → {"data":[{...}],"total":1,"limit":50,"offset":0}Field validation is enforced at write and mapped to HTTP:
curl -X POST localhost:3000/api/user -H 'content-type: application/json' -d '{
"id":"22222222-2222-2222-2222-222222222222","username":"bob",
"email":"not-an-email","password_hash":"x","created_at":0,"posts":null
}'
# → 422 {"error":"field `email` violates `email`: must be a valid email address"}@email/@min/@max/@length/@url violations return 422; a &unique collision
or a dangling foreign key returns 409.
Create contract
Both the Rust db.create_<model> path and the REST POST /api/<model> that routes through
it auto-generate every + field — uuid, timestamp, and integer u32/u64: omit
id/created_at from the JSON body (or send a nil/zero/0 value) and the server fills them.
You still send the concrete scalar fields plus virtual relation fields as null (e.g.
"posts":null).
One honest caveat: the generated TypeScript SDK computes its <Model>Create type as
Omit<Model, 'id'> — keyed on the literal name id rather than on what the server actually
synthesizes. So it still lists the other + fields, and it drops id even for a model whose
identity is not server-assigned. The Rust, Python, and Go SDKs derive the create shape
correctly (#259).
Full route set per model: GET /api/<model> (list, with ?limit&offset&sort&<field>=),
POST /api/<model> (create), GET|PUT|DELETE /api/<model>/{id}.
7. Use the typed client SDK#
The SDK you generated in step 3 is full CRUD, faithful to the REST contract — same methods and shapes in every language. Pick your ecosystem above:
forgedb generate node --sdk (or bun --sdk) emits generated/types.ts plus a
package.json/tsconfig.json (only if absent — regeneration never clobbers your edits), so
it's npm-publishable as-is:
import { ForgeDBClient } from './generated/types';
const db = new ForgeDBClient('http://localhost:3000');
// list → ListResult<T> = { data, total, limit, offset }
const { data, total } = await db.listUser({ limit: 20, sort: 'username' });
// get → the row, or null on 404
const user = await db.getUser('11111111-1111-1111-1111-111111111111');
// create → the new id; throws ForgeDBError on 409/422
const id = await db.createUser({
username: 'grace', email: 'grace@example.com',
password_hash: 'x', created_at: Date.now(), posts: null,
});
// update → false if the id doesn't exist; delete → true/false
await db.updateUser(id, { /* full record */ });
await db.deleteUser(id);No SDK? The REST API is plain HTTP — call it with fetch:
const res = await fetch('http://localhost:3000/api/user', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: 'grace', email: 'grace@example.com', password_hash: 'x', posts: null,
}),
});
const { id } = await res.json(); // → { id: "<server-generated uuid>" }forgedb generate python --sdk emits generated/python-sdk/forgedb_client.py, a
stdlib-urllib client with no runtime dependencies:
from forgedb_client import ForgeDbClient, UserCreate, ListOptions
db = ForgeDbClient("http://localhost:3000")
# list → ListResult { data, total, limit, offset }
page = db.list_user(ListOptions(sort="username", limit=20))
# get → the row, or None on 404
user = db.get_user("11111111-1111-1111-1111-111111111111")
# create → the new id; raises ForgeDbError on 409/422
user_id = db.create_user(UserCreate(
username="grace",
email="grace@example.com",
password_hash="x",
created_at=0,
))
# update → False if the id doesn't exist; delete → True/False
db.delete_user(user_id)forgedb generate rust --sdk emits generated/rust-sdk/, a reqwest-based client crate —
add it as a path/git dependency and use the typed ForgeDbClient:
use forgedb_client::{ForgeDbClient, UserCreate, ListOptions};
let db = ForgeDbClient::new("http://localhost:3000");
// list → ListResult<User> { data, total, limit, offset }
let page = db.list_user(&ListOptions {
sort: Some("username".into()),
limit: Some(20),
..Default::default()
}).await?;
// get → Option<User> (None on 404)
let user = db.get_user("11111111-1111-1111-1111-111111111111").await?;
// create → the new id; Err(ForgeDbError) on 409/422
let id = db.create_user(&UserCreate {
username: "grace".into(),
email: "grace@example.com".into(),
password_hash: "x".into(),
created_at: 0,
..Default::default()
}).await?;forgedb generate go --sdk emits generated/go-sdk/client.go, a net/http client package
(forgedbclient) plus a go.mod:
db := forgedbclient.NewClient("http://localhost:3000")
// ListUser → *ListResult[User] { Data, Total, Limit, Offset }
limit := 20
page, err := db.ListUser(&forgedbclient.ListOptions{Sort: "username", Limit: &limit})
// GetUser → (*User, error); nil on 404
user, err := db.GetUser("11111111-1111-1111-1111-111111111111")
// CreateUser → the new id; non-nil err on 409/422
id, err := db.CreateUser(&forgedbclient.UserCreate{
Username: "grace",
Email: "grace@example.com",
PasswordHash: "x",
})Each SDK surfaces write errors as a typed error carrying the HTTP status and parsed body
(TS ForgeDBError, Python/Rust ForgeDbError, Go's returned error) and maps get/delete
404s to a null / None / false result.
Next steps#
- Schema language — the complete
.forgereference. - Core concepts — the generation pipeline and identity invariant.
- What pre-1.0 is (and isn't) — the honest scope: guarantees and limits of v1.