<p align="center">
<img src="assets/logo.svg" alt="Caravela" width="180" height="180"/>
</p>
# Caravela
*Declare your domain. Sail with the generated code.*
A schema-driven, composable full-stack framework for Phoenix projects.
You describe a domain (entities, fields, relations, hooks, permissions)
as an Elixir DSL; Caravela generates Ecto schemas, migrations, Phoenix
contexts, JSON controllers, LiveViews, and typed Svelte components.
> **Status — Phase 2.** The DSL, compiler, schema + migration
> generators (Phase 1) plus hooks, permissions, context, and JSON API
> generators (Phase 2) are in place. LiveView, Svelte, and Flow
> orchestration land in later phases.
## Installation
Add `caravela` to your deps in `mix.exs`:
```elixir
def deps do
[
{:caravela, "~> 0.2.0"}
]
end
```
Phoenix and `ecto_sql` are assumed to already be present in the host
app; Caravela generates code against them.
## Quick start
### 1. Declare a domain
```elixir
# lib/my_app/domains/library.ex
defmodule MyApp.Domains.Library do
use Caravela.Domain
entity :authors do
field :name, :string, required: true
field :bio, :text
field :born, :date
end
entity :books do
field :title, :string, required: true, min_length: 3
field :isbn, :string, format: ~r/^\d{13}$/
field :published, :boolean, default: false
field :price, :decimal, precision: 10, scale: 2
end
entity :publishers do
field :name, :string, required: true
field :country, :string
end
relation :authors, :books, type: :has_many
relation :books, :publishers, type: :belongs_to
# Hooks
on_create :books, fn changeset, _context ->
if Ecto.Changeset.get_field(changeset, :published) do
Ecto.Changeset.validate_required(changeset, [:published_at])
else
changeset
end
end
on_update :books, fn changeset, _context ->
if Ecto.Changeset.get_change(changeset, :published) == true do
Ecto.Changeset.put_change(changeset, :published_at, DateTime.utc_now())
else
changeset
end
end
# Permissions
can_create :books, fn context ->
context.current_user.role in [:admin, :editor]
end
can_update :books, fn book, context ->
context.current_user.role == :admin or
book.author_id == context.current_user.author_id
end
can_delete :books, fn _book, context ->
context.current_user.role == :admin
end
end
```
### 2. Generate everything
```bash
mix caravela.gen MyApp.Domains.Library
# * created priv/repo/migrations/…_create_library_tables.exs
# * created lib/my_app/library/author.ex
# * created lib/my_app/library/book.ex
# * created lib/my_app/library/publisher.ex
# * created lib/my_app/library.ex (context)
# * created lib/my_app_web/controllers/author_controller.ex
# * created lib/my_app_web/controllers/book_controller.ex
# * created lib/my_app_web/controllers/publisher_controller.ex
#
# (and prints a router scope snippet to paste into router.ex)
```
Or target a single layer:
```bash
mix caravela.gen.schema MyApp.Domains.Library # schemas + migration only
mix caravela.gen.context MyApp.Domains.Library # context only
mix caravela.gen.api MyApp.Domains.Library # controllers + router scope
```
Pass `--dry-run` to preview, or `--force` to overwrite without prompts.
### 3. Migrate and run
```bash
mix ecto.migrate
mix phx.server
curl -X POST localhost:4000/api/books \
-H "content-type: application/json" \
-d '{"title":"Test Title"}'
# → 201 Created on valid input, 403 if can_create denies,
# 422 if the changeset fails validation or the hook rejects it.
```
## DSL reference
### `entity :<name> do ... end`
Declares one entity (one table). The name is plural (`:books`); the
generator derives a singular module name (`Book`), a plural table name
(`library_books`), and a path (`lib/<app>/library/book.ex`).
### `field :<name>, <type>, opts`
| option | applies to | effect |
|----------------------------|-------------------|-------------------------------------|
| `required` | any | `null: false` + `validate_required` |
| `default` | any | column default |
| `min`, `max` | numeric | `validate_number` |
| `min_length`, `max_length` | string-like | `validate_length` |
| `format` | string-like | `validate_format` (regex) |
| `precision`, `scale` | numeric | decimal precision/scale |
Recognised types: `:string`, `:text`, `:integer`, `:bigint`, `:float`,
`:decimal`, `:boolean`, `:date`, `:time`, `:naive_datetime`,
`:utc_datetime`, `:binary`, `:binary_id`, `:uuid`, `:map`, `:json`,
`:jsonb`.
### `relation :<from>, :<to>, type: <t>`
`t` is one of `:has_many`, `:has_one`, `:belongs_to`, `:many_to_many`.
Declare either side of a relationship — Caravela infers the other.
### Hooks: `on_create`, `on_update`, `on_delete`
Hooks run inside the generated context, between authorization and the
final `Repo` call:
```elixir
on_create :books, fn changeset, context -> ... end # → changeset
on_update :books, fn changeset, context -> ... end # → changeset
on_delete :authors, fn author, context -> ... end # → :ok | {:error, reason}
```
`context` is whatever map you pass to the context function. In the
generated controllers it defaults to `%{current_user: …, conn: conn}`.
If a `{:error, reason}` is returned from `on_delete`, the delete is
aborted and the tuple propagates back to the caller.
### Permissions: `can_read`, `can_create`, `can_update`, `can_delete`
```elixir
can_read :books, fn query, context -> query end # → Ecto.Query
can_create :books, fn context -> true end # → boolean
can_update :books, fn book, context -> true end # → boolean
can_delete :books, fn _book, context -> true end # → boolean
```
`can_read` is applied as a query filter before `Repo.all`/`Repo.get`,
so restricted users never see forbidden rows. The other three return
booleans; a `false` short-circuits the context function with
`{:error, :unauthorized}`.
To use query macros like `where` / `from` inside `can_read`, add
`import Ecto.Query` at the top of your domain module.
## Compile-time validations
Every rule raises a `CompileError` pointing at the offending line:
1. Unknown field types (`:widget` etc.)
2. Numeric constraints on non-numeric fields (and vice versa)
3. Duplicate entity names
4. Relations referencing undeclared entities
5. Incompatible cardinality (e.g. both sides `:has_many`)
6. Circular chains of required `belongs_to` (unsatisfiable inserts)
7. Hooks / permissions with the wrong function arity
8. Hooks / permissions referring to unknown entities
9. Duplicate hook / permission for the same (action, entity)
## Regeneration safety — the `# --- CUSTOM ---` marker
Every generated file (schemas, context, controllers) ends with:
```elixir
# --- CUSTOM ---
# Custom code below this line is preserved on regeneration.
end
```
Anything you write below that line is preserved verbatim the next time
you run `mix caravela.gen`. Migrations are always emitted as fresh
timestamped files — write bridging `ALTER TABLE` migrations yourself.
## Primary keys and ids
Every generated schema uses `:binary_id` (UUID) primary and foreign
keys. No enumeration attacks, no sequence exhaustion, Ecto-native.
## What's in Phase 1 + 2
**Phase 1** — `Caravela.Domain` DSL (`entity`, `field`, `relation`), the
compiler with six validations, Ecto-schema and migration generators,
`mix caravela.gen.schema`.
**Phase 2** — hook DSL (`on_create`, `on_update`, `on_delete`),
permission DSL (`can_read`, `can_create`, `can_update`, `can_delete`),
Phoenix context generator, JSON controller generator, router-scope
printer, regeneration-safe `# --- CUSTOM ---` marker,
`mix caravela.gen.context`, `mix caravela.gen.api`, and `mix caravela.gen`.
## Roadmap
Later phases add LiveView modules that mount Svelte components via
LiveSvelte, typed Svelte component generation, Absinthe/GraphQL schema
generation, and a GenServer-backed flow runtime for composable async
workflows.
## License
Caravela is licensed under the
[Mozilla Public License 2.0](LICENSE) (MPL-2.0). In short:
- You can use Caravela — including in closed-source Phoenix applications
— freely.
- If you modify a Caravela source file and distribute it, that file must
stay under MPL-2.0, with authorship and copyright notices intact.
- You may not strip attribution or pass this work off as your own.
See [NOTICE](NOTICE) for the full attribution and anti-plagiarism
statement.
## Supporting the project
Caravela is built in the open and free to use. If it saves you time or
ships something you're proud of, please consider sponsoring its
development — donation channels (GitHub Sponsors, Open Collective, etc.)
will be linked here once set up.
Every contribution, from a PR to a coffee, helps keep the sails full.