forgedb 0.2.0

ForgeDB — an application database generator. Compiles a declarative .forge schema into tailored Rust database code, a TypeScript SDK, and a REST API.
Documentation
---
title: "Quickstart"
description: "From an empty directory to a running, type-safe database server with a typed TypeScript client — the whole init → generate → build → serve loop."
purpose: "orientation"
---
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.

<Callout type="note" title="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)](/docs/what-pre-1-0-is/).
</Callout>

## 1. Install

```bash
cargo install forgedb
```

Other install paths (prebuilt binaries, `--git`, from a clone) are in
[installation](/docs/installation/). Verify:

```bash
forgedb --version      # forgedb 0.1.0
```

## 2. Scaffold a project

```bash
forgedb init myblog --template blog --rust
cd myblog
```

`--template` accepts `blog`, `ecommerce`, `todo`, or `blank` (the default). `--rust`
includes the Rust backend scaffold. `init` writes:

```
myblog/
  schema.forge          # your schema (the single source of truth)
  forgedb.toml          # project + database + api + codegen config
  Cargo.toml            # pins the schema-agnostic substrate crates
  src/main.rs           # env-driven axum server (tenancy, JWT, graceful shutdown)
  Dockerfile            # multi-stage build → slim runtime
  .dockerignore
  docker-compose.yml
  deploy/               # systemd unit + env file
  .gitignore
  README.md
```

The blog template's `schema.forge`:

```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](/docs/schema/overview/) for the full grammar.

## 3. Generate code

```bash
forgedb generate rust         # → generated/database.rs
forgedb generate api          # → generated/api.rs (+ package.json, tsconfig.json)
forgedb generate node --sdk   # → generated/types.ts  (the REST SDK; `bun --sdk` is equivalent)
```

Or `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.

<DiveDeeper summary="what's inside the generated database.rs">

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.

</DiveDeeper>

## 4. Build

```bash
cargo build
```

The generated app links only the small, **schema-agnostic** substrate crates
(`forgedb-storage`, `forgedb-wal`, `forgedb-types`, …) that `init` pinned in `Cargo.toml`;
they resolve from crates.io. See the substrate version matrix in
[installation](/docs/installation/).

## 5. Run the server

The generated `main.rs` is an axum server configured entirely from the environment:

```bash
FORGEDB_PORT=3000 FORGEDB_DATA=./data ./target/debug/myblog
# INFO myblog: ForgeDB serving tenant=None data_root=./data addr=127.0.0.1:3000
```

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:

```bash
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}
```

<DiveDeeper summary="liveness vs readiness, and what each probe checks">

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.

</DiveDeeper>

## 6. Use the REST API

Each model gets a REST resource under `/api/<model>`:

```bash
# 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":[{...}],"limit":50,"offset":0,"total":1}
```

Field validation is enforced at write and mapped to HTTP:

```bash
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**.

<Callout type="note" title="Create contract">
Both the Rust `db.create_<model>` path and the REST `POST /api/<model>` that routes through
it **auto-generate `+uuid` and `+timestamp` fields**: omit `id`/`created_at` from the JSON
body (or send a nil/zero value) and the server fills them. You still send the concrete
scalar fields plus virtual relation fields as `null` (e.g. `"posts":null`). The generated TS
SDK's `<Model>Create` type omits `id` (server-assigned).

Two honest caveats: integer `+u32`/`+u64` keys are **not** yet auto-incremented, so a create
must supply them; and beyond `id`, the SDK's typed `Create` body still lists the other `+`
fields for now ([#187](https://github.com/hoodiecollin/forgedb/issues/187),
[#188](https://github.com/hoodiecollin/forgedb/issues/188)).
</Callout>

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 TypeScript SDK

`forgedb generate node --sdk` (or `bun --sdk`) emits `generated/types.ts` plus a
`package.json` and `tsconfig.json` (only if absent — regeneration never clobbers your
edits), so it's npm-publishable as-is. The client is full CRUD, faithful to the REST
contract:

```typescript
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);
```

Errors surface as a typed `ForgeDBError` carrying the HTTP status and parsed body (except
get/delete 404s, which return `null`/`false`).

## Next steps

- **[Schema language](/docs/schema/overview/)** — the complete `.forge` reference.
- **[Core concepts](/docs/concepts/)** — the generation pipeline and identity invariant.
- **[What pre-1.0 is (and isn't)](/docs/what-pre-1-0-is/)** — the honest scope: guarantees and
  limits of v1.