Define your data model once. Get migrations, CRUD endpoints, and API docs automatically.
SchemaForge is a Rust toolkit that turns human-readable schema definitions into fully operational backends -- no recompilation required. Write a schema file (or describe what you need in plain English), and SchemaForge generates database tables, CRUD API endpoints, migrations, authorization policies, and API documentation at runtime.
schema Contact {
name: text(max: 255) required indexed
email: text(max: 512) required indexed
phone: text
priority: enum("low", "medium", "high") default("medium")
company: -> Company
tags: text[]
notes: richtext
is_active: boolean default(true)
}
From this single file, SchemaForge generates:
- Database tables with type enforcement and constraints (SurrealDB)
- REST API routes with input validation for every entity
- Migration plans that diff against your existing schema
- Cedar authorization policies for access control
- OpenAPI specifications that stay in sync with your schemas
- Why SchemaForge
- Quick Start
- Architecture
- SchemaDSL Reference
- CLI Reference
- AI Agent
- Programmatic Usage
- Project Status
- Design Decisions
- Contributing
Traditional backend development requires you to define your model in code, write a migration, build CRUD handlers, add validation, wire up authorization, and generate API docs -- separately, for every entity. When a schema changes, you repeat the cycle.
SchemaForge collapses that workflow. One schema file is the single source of truth for your entire entity lifecycle. Change the schema, and everything downstream updates automatically: migrations are computed by diffing versions, routes adapt, validation adjusts, and the OpenAPI spec regenerates.
The AI agent takes this further. Describe what you need in plain English, and an LLM generates the schema, validates it through the tool execution loop (self-correcting any errors), and applies it to your database -- all without writing DSL by hand.
- A running SurrealDB 2.x or PostgreSQL 14+ instance (SurrealDB embedded mode works for development)
- Rust 1.75+ only if you intend to build from source
Each release ships a single statically-linked schemaforge binary for Linux x86_64, built against exactly one database backend. Pick the flavor that matches your target database and run the matching one-liner:
# PostgreSQL build
TAG=$(curl -fsSL https://api.github.com/repos/Govcraft/schemaforge/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && \
curl -fsSL "https://github.com/Govcraft/schemaforge/releases/download/${TAG}/schemaforge-${TAG}-x86_64-unknown-linux-gnu-postgres.tar.gz" \
| sudo tar -xz -C /usr/local/bin# SurrealDB build
TAG=$(curl -fsSL https://api.github.com/repos/Govcraft/schemaforge/releases/latest | grep '"tag_name"' | cut -d'"' -f4) && \
curl -fsSL "https://github.com/Govcraft/schemaforge/releases/download/${TAG}/schemaforge-${TAG}-x86_64-unknown-linux-gnu-surrealdb.tar.gz" \
| sudo tar -xz -C /usr/local/binEach command downloads the latest release tarball, extracts the schemaforge binary into /usr/local/bin, and leaves it immediately runnable. To install somewhere on your PATH without sudo, swap /usr/local/bin for a user-writable directory such as ~/.local/bin.
The backend is compiled in — there is no runtime flag to switch between PostgreSQL and SurrealDB. If you need both, download both tarballs and rename the binaries (e.g. schemaforge-pg, schemaforge-surreal).
Verify the install:
schemaforge --versionIf you need a different target triple, or want to track main:
# PostgreSQL build
cargo install --git https://github.com/Govcraft/schemaforge schema-forge-cli \
--no-default-features --features postgres
# SurrealDB build
cargo install --git https://github.com/Govcraft/schemaforge schema-forge-cli \
--no-default-features --features surrealdbschemaforge init my-platform
cd my-platformThis creates:
my-platform/
├── Cargo.toml
├── config.toml
├── acton-ai.toml
├── schemas/
├── policies/
│ ├── generated/
│ └── custom/
└── src/
└── main.rs
Create a file at schemas/crm.schema:
@version(1)
@display("name")
schema Company {
name: text(max: 255) required indexed
website: text(max: 500)
industry: enum("fintech", "saas", "healthcare", "other")
employee_count: integer(min: 1)
address: composite {
street: text
city: text required
state: text
zip: text
country: text required
}
}
@version(1)
@display("email")
schema Contact {
first_name: text(max: 100) required
last_name: text(max: 100) required
email: text(max: 255) required indexed
phone: text(max: 20)
status: enum("active", "inactive", "lead") default("lead")
company: -> Company
tags: text[]
notes: richtext
}
# Parse and validate your schemas
schemaforge parse schemas/
# Apply schemas to SurrealDB (creates tables, fields, indexes)
schemaforge apply schemas/ --db-url ws://localhost:8000
# Preview migration steps without applying
schemaforge apply schemas/ --dry-run
# Start the API server with dynamic CRUD routes
schemaforge serve --schemas schemas/ --db-url ws://localhost:8000 --db-ns app --db-name mainOnce served, every registered schema automatically gets REST endpoints:
POST /forge/entities/contact Create a contact
GET /forge/entities/contact List/query contacts
GET /forge/entities/contact/:id Get a contact by ID
PUT /forge/entities/contact/:id Update a contact
DELETE /forge/entities/contact/:id Delete a contact
GET /forge/schemas List all schemas
GET /forge/openapi.json Dynamic OpenAPI specification
Instead of writing DSL by hand, describe what you need:
# One-shot generation
schemaforge generate "A ticketing system with tickets linked to contacts,
priority levels, status tracking, and assignment" --batch -o schemas/ticketing.schema
# Interactive conversational mode
schemaforge generateThe AI agent calls list_schemas to see what already exists, generates DSL, calls validate_schema to check correctness, fixes any errors automatically, and applies the result after confirmation. No custom retry logic -- the LLM's tool execution loop handles self-correction naturally.
SchemaForge is a Cargo workspace of seven composable crates. Each layer depends only on the layers below it.
┌─────────────────┐
"I need a CRM..." ─────>│ schema-forge-ai │ LLM agent + tools
└────────┬────────┘
│ generates DSL
┌────────▼────────┐
.schema files ──────────>│ schema-forge-dsl │ lexer + parser + printer
└────────┬────────┘
│ produces SchemaDefinition
┌────────▼────────┐
│schema-forge-core │ types, validation, migration, query IR
└────────┬────────┘
│ implements traits
┌─────────────────┐ ┌────────▼────────┐
│schema-forge-acton│──────>│schema-forge- │ SchemaBackend + EntityStore traits
│ HTTP routes │ │backend │
└─────────────────┘ └────────┬────────┘
│
┌─────────────────┐ ┌────────▼────────┐
│ schema-forge-cli│ │schema-forge- │ SurrealQL codegen + CRUD
│ commands │──────>│surrealdb │
└─────────────────┘ └─────────────────┘
| Crate | Purpose |
|---|---|
schema-forge-core |
Runtime type system, validation, migration planner, query IR. Zero I/O, pure logic. |
schema-forge-dsl |
Lexer (logos) and recursive descent parser for .schema files, plus a printer for round-trip fidelity. |
schema-forge-backend |
SchemaBackend and EntityStore trait definitions. Storage-agnostic interface. |
schema-forge-surrealdb |
SurrealDB implementation: MigrationStep to SurrealQL compilation, entity CRUD, query translation. |
schema-forge-postgres |
PostgreSQL implementation: DDL codegen, entity CRUD, query translation via SQLx. |
schema-forge-acton |
Axum-based JSON API layer: dynamic CRUD routes, auth/login, Cedar policies, OpenAPI spec, schema registry. |
schema-forge-cli |
Command-line interface: init, parse, apply, migrate, generate, serve, inspect, export, policies. |
The foundational types in schema-forge-core model schemas with validated newtypes:
- SchemaName -- PascalCase identifier (e.g.,
Contact,OrderItem) - FieldName -- snake_case identifier (e.g.,
first_name,email_address) - SchemaVersion -- Positive integer, auto-incremented
- SchemaId -- TypeID-based unique identifier (UUIDv7)
- FieldType -- The complete type system:
Text,RichText,Integer,Float,Boolean,DateTime,Enum,Json,Relation,Array,Composite - FieldModifier --
Required,Indexed,Default(value)
All types derive Serialize/Deserialize and use #[non_exhaustive] for forward compatibility.
The DiffEngine compares two schema versions and produces a MigrationPlan -- an ordered list of atomic MigrationStep operations:
| Step | Safety Level |
|---|---|
CreateSchema, AddField, AddIndex, AddRelation |
Safe |
RenameField, ChangeType, AddRequired |
Requires confirmation |
DropSchema, RemoveField, RemoveRelation |
Destructive |
Each step carries a safety classification. The CLI shows the migration plan and prompts for confirmation before executing destructive steps.
Type changes include automatic value transforms where possible (integer to float, any scalar to string) and fall back to SetNull for incompatible conversions.
A storage-agnostic Filter enum compiles to native backend queries. It supports comparison operators (Eq, Ne, Gt, Gte, Lt, Lte), string operations (Contains, StartsWith), set membership (In), and logical combinators (And, Or, Not).
FieldPath enables dotted notation for relation traversal. The query company.industry = "fintech" traverses the company relation and filters on the industry field -- translated to native SurrealDB dot-notation without JOINs.
SurrealDB is the primary backend. Its data model aligns naturally:
| SchemaForge Concept | SurrealDB Equivalent |
|---|---|
SchemaDefinition |
DEFINE TABLE + DEFINE FIELD statements |
FieldType::Text |
TYPE string with assertion on length |
FieldType::Enum |
TYPE string with ASSERT $value IN [...] |
FieldType::Relation (one) |
TYPE option<record<Target>> |
FieldType::Relation (many) |
TYPE option<array<record<Target>>> |
FieldType::Composite |
TYPE object with nested DEFINE FIELD |
FieldType::Json |
FLEXIBLE TYPE object |
| Relation traversal | Native dot-notation (no JOINs) |
The embedded SurrealDB mode (kv-mem) enables development and testing without running a separate database process.
| Type | Syntax | Constraints |
|---|---|---|
| Text | text or text(max: 255) |
min, max character length |
| Rich Text | richtext |
min, max character length |
| Integer | integer or integer(min: 0, max: 100) |
min, max bounds |
| Float | float or float(precision: 2) |
precision (decimal places) |
| Boolean | boolean |
None |
| DateTime | datetime |
None |
| Enum | enum("a", "b", "c") |
At least 1 variant, no duplicates |
| Relation (one) | -> SchemaName |
Target must be PascalCase |
| Relation (many) | -> SchemaName[] |
Target must be PascalCase. See Inverse collections below. |
| Array | text[], integer[], etc. |
Suffix [] on any field type |
| Composite | composite { field: type ... } |
Nested field definitions |
| JSON | json |
Arbitrary unstructured data |
A collection relation on a parent (documents: -> Document[]) is resolved
as an inverse view when the target schema declares a foreign-key
relation pointing back:
schema Opportunity {
title: text required
documents: -> Document[] // derived — no physical column
}
schema Document {
title: text required
opportunity: -> Opportunity // FK on the child side
}
- Reads of
Opportunity.documentsqueryDocumentfiltered by theopportunityFK and return the matching child IDs (plus__displayentries via the usual display-field machinery). Parents with no children get an empty array — nevernull. - Writes to
Opportunity.documentsare rejected with422. Persist the relationship by settingDocument.opportunityon the child. - Migrations for a paired field emit no column: nothing to backfill, nothing to drop, no drift.
If the target schema has no FK pointing back, -> X[] keeps its
older behavior as a stored TEXT[] / array<record<X>> column — use
that for many-to-many / tag-style lists where both sides are
independent.
Exactly one FK back is required. Two FKs from the same child pointing at
the same parent is rejected at schema-load time with an ambiguous
inverse error — disambiguate by removing the duplicate FK (SchemaForge
does not currently support @inverse(field: "...") to pick between
competing FKs).
| Modifier | Effect |
|---|---|
required |
Field must have a non-null value |
indexed |
Field is indexed for fast lookups |
default(value) |
Sets a default when the field is omitted |
Annotations appear before the schema keyword:
@version(2)
@display("email")
schema Contact { ... }
@version(N)-- Declares the schema version (positive integer).@display("field_name")-- Identifies the display field for the schema.
- Schema names must be PascalCase:
Contact,OrderItem,UserProfile - Field names must be snake_case:
first_name,email_address,created_at
program = { schema_def } ;
schema_def = { annotation } "schema" PASCAL_IDENT "{" { field_def } "}" ;
field_def = SNAKE_IDENT ":" field_type { modifier } ;
field_type = primitive_type [ "[]" ]
| "->" PASCAL_IDENT [ "[]" ]
| "composite" "{" { field_def } "}"
;
primitive_type = "text" [ "(" text_params ")" ]
| "richtext" [ "(" text_params ")" ]
| "integer" [ "(" int_params ")" ]
| "float" [ "(" float_params ")" ]
| "boolean"
| "datetime"
| "enum" "(" enum_variants ")"
| "json"
;
modifier = "required" | "indexed" | "default" "(" value ")" ;
annotation = "@version" "(" INTEGER ")"
| "@display" "(" STRING ")"
| "@system"
| "@access" "(" named_string_lists ")"
| "@tenant" "(" tenant_kind ")"
| "@dashboard" "(" dashboard_params ")"
| "@webhook" [ "(" webhook_params ")" ]
;
webhook_params = "events" ":" string_list
[ "," "url" ":" STRING [ "," "secret" ":" STRING ] ] ;schemaforge <command> [options]
| Command | Description |
|---|---|
init <name> |
Scaffold a new project (--template minimal|full|api-only) |
parse <paths> |
Validate .schema files and show diagnostics (--print for round-trip output) |
apply <paths> |
Apply schemas to the backend (--dry-run, --force, --with-policies) |
migrate <paths> |
Show migration plan (--execute to apply, --schema for a specific schema) |
generate [desc] |
Generate schemas from natural language (--batch, --provider, --model) |
serve |
Start HTTP server with dynamic routes (--host, --port, --watch) |
inspect [schema] |
Show registered schemas and details (--detail, --counts) |
export openapi |
Export OpenAPI spec (-o file) |
policies list |
List Cedar authorization policies |
policies regenerate |
Regenerate Cedar policy templates (--force) |
completions <shell> |
Generate shell completions (bash, zsh, fish, powershell, elvish) |
| Option | Description |
|---|---|
-c, --config <path> |
Configuration file path (env: SCHEMA_FORGE_CONFIG) |
--format human|json|plain |
Output format (default: human) |
-v, --verbose |
Increase verbosity (-v, -vv, -vvv) |
-q, --quiet |
Suppress non-error output |
--no-color |
Disable colored output (env: NO_COLOR) |
--db-url <url> |
SurrealDB connection URL (env: SCHEMA_FORGE_DB_URL) |
--db-ns <name> |
SurrealDB namespace (env: SCHEMA_FORGE_DB_NS) |
--db-name <name> |
SurrealDB database name (env: SCHEMA_FORGE_DB_NAME) |
The AI agent uses acton-ai to connect an LLM to SchemaForge through four custom tools:
| Tool | Purpose |
|---|---|
validate_schema |
Parse and validate DSL; returns structured errors for self-correction |
list_schemas |
Show existing schemas as DSL for context |
apply_schema |
Register schemas and execute migrations (supports dry-run) |
generate_cedar |
Create Cedar authorization policy templates |
The agent workflow is straightforward: the LLM generates DSL, calls validate_schema, reads any errors, fixes the DSL, and validates again. This self-correction loop is not custom retry logic -- it is the natural behavior of an LLM tool execution loop. The grammar is small enough that even 7B parameter local models produce valid schemas consistently.
Configure AI providers in acton-ai.toml:
default_provider = "ollama"
[providers.ollama]
type = "ollama"
model = "qwen2.5:7b"
base_url = "http://localhost:11434/v1"
timeout_secs = 120
max_tokens = 4096
temperature = 0.3
[providers.cloud]
type = "anthropic"
model = "claude-sonnet-4-20250514"
api_key_env = "ANTHROPIC_API_KEY"Use --provider to select a provider at generation time, or let auto pick from the configuration.
Add the crates you need to your Cargo.toml:
[dependencies]
schema-forge-core = "0.2"
schema-forge-dsl = "0.1"
schema-forge-backend = "0.1"
schema-forge-surrealdb = "0.2"
schema-forge-acton = "0.1"use schema_forge_dsl::{parse, print};
let source = r#"
schema Contact {
name: text(max: 255) required
email: text required indexed
active: boolean default(true)
}
"#;
let schemas = parse(source).expect("parse failed");
assert_eq!(schemas[0].name.as_str(), "Contact");
// Round-trip: parse -> print -> parse produces equivalent AST
let dsl_text = print(&schemas[0]);use schema_forge_acton::SchemaForgeExtension;
use schema_forge_surrealdb::SurrealBackend;
let backend = SurrealBackend::connect("ws://localhost:8000").await?;
let extension = SchemaForgeExtension::builder()
.with_backend(backend.clone())
.with_auth_store(backend)
.with_admin_credentials("admin".into(), "changeme".into())
.build()
.await?;
// Register JSON forge routes under /forge on any axum Router.
// The UI is generated separately with `schemaforge site generate`, which
// writes a React + Vite project that talks to this API. See
// docs/site-guide.md for the end-to-end workflow.
let app = extension.register_routes(axum::Router::new());See docs/site-guide.md for the React site generator workflow, including the /app/* vs /admin/* route trees, template override loader, auth bootstrap, and field-type widget reference.
use schema_forge_core::migration::DiffEngine;
// Compare two schema versions
let plan = DiffEngine::diff(&old_schema, &new_schema);
println!("{}", plan);
// Migration plan for 'Contact' (3 steps, safe)
// 1. ADD field 'phone' [safe]
// 2. ADD field 'status' [safe]
// 3. ADD INDEX on 'email' [safe]
if plan.has_destructive_steps() {
// Prompt for confirmation before applying
}SchemaForge is under active development. All seven crates compile and pass 1123 tests across the workspace (unit, integration, property-based, and round-trip tests).
| Crate | Version | Tests |
|---|---|---|
schema-forge-core |
0.6.0 | 293 |
schema-forge-dsl |
0.3.0 | 170 |
schema-forge-backend |
0.5.0 | 41 |
schema-forge-surrealdb |
0.4.0 | 60 |
schema-forge-postgres |
0.2.0 | 48 |
schema-forge-acton |
0.11.0 | 388 |
schema-forge-cli |
0.8.0 | 123 |
- Full runtime type system with validated newtypes and serde round-trips
- DSL lexer (logos), recursive descent parser, and printer with round-trip fidelity
- Migration engine: schema diffing, safety classification, value transforms
- Storage-agnostic query IR with relation traversal
- SurrealDB backend: DDL codegen, entity CRUD, query translation
- PostgreSQL backend: DDL codegen, entity CRUD, query translation
- Axum JSON API with dynamic CRUD routes and schema management
- React site generator (
schemaforge site generate) producing a Vite + Tailwind + shadcn app against the JSON API - Token-based authentication (PASETO) with an auth-store-backed login endpoint
- Cedar authorization policy generation
- Schema-level and field-level access control via
@accessand@field_accessannotations - Record-level ownership-based access control
- Multi-tenant support via
@tenantannotations - Webhook notifications for entity CRUD events
- OpenAPI spec generation from the schema registry
- OpenTelemetry tracing and metrics integration
- CLI with 10 commands, global options, environment variable support, and shell completions
- SQLite backend
- Watch mode for hot-reloading schema changes during development
Why a custom DSL? The grammar is small, git-trackable, and code-reviewable. Its simplicity gives LLMs a high success rate even with small local models. The parser and printer guarantee lossless round-trip conversion.
Why SurrealDB and PostgreSQL? SurrealDB's native record links map directly to SchemaForge relations, and its embedded mode means no external process for development. PostgreSQL provides production-grade reliability and ecosystem compatibility. Both backends implement the same SchemaBackend and EntityStore traits — switching is a feature flag change.
Why acton-ai for the agent? The tool execution loop is the self-correction loop. No custom retry logic, no bespoke error handling -- the LLM calls tools, reads results, and adapts. Multi-provider support (local and cloud models) comes free from the configuration.
Why storage-agnostic traits? The SchemaBackend and EntityStore traits use RPITIT (return position impl Trait in trait) for async methods without async-trait. Adding a new backend means implementing two traits -- everything else (parsing, validation, migration planning, query construction, route handling) stays the same.
Contributions are welcome. The project uses standard Rust tooling:
# Run all tests
cargo test --workspace
# Check formatting
cargo fmt --all -- --check
# Run clippy
cargo clippy --workspace -- -D warningsSee the project repository for license information.