README.md

<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 3.** Phases 1–2 (DSL, compiler, schemas, migrations,
> hooks, permissions, context, JSON API) plus Phase 3 (multi-tenancy,
> versioning, Absinthe/GraphQL generation) 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.3.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
mix caravela.gen.graphql MyApp.Domains.Library   # Absinthe types + queries + mutations
```

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.

## Multi-tenancy and versioning

Row-level multi-tenancy and API versioning are opt-in at the domain
level:

```elixir
defmodule MyApp.Domains.Library do
  use Caravela.Domain, multi_tenant: true

  version "v1"

  entity :books do
    field :title, :string, required: true
    # tenant_id is auto-injected — don't declare it
  end
end
```

With `multi_tenant: true`:

- A `:tenant_id` `:binary_id` column (`null: false`) is added to every
  entity, with a composite `[:tenant_id, :<fk>]` index alongside each
  foreign-key index.
- The generated context scopes every read with
  `where(q.tenant_id == ^tenant_id)` and stamps every create with
  `put_change(:tenant_id, tenant_id)` — both driven by
  `context.tenant.id` at the call site.
- Generated controllers read `conn.assigns[:tenant]` into the context
  automatically. Plug the tenant in ahead of your `:api` pipeline (from
  a subdomain, a header, or a session claim).

With `version "v1"`:

- All generated Elixir modules are namespaced under the version segment
  (`MyApp.Library.V1.Book`, `MyAppWeb.V1.BookController`).
- Schema files move under `lib/<app>/<context>/v1/…`.
- The printed router snippet uses `scope "/api/v1", MyAppWeb.V1`.
- Table names stay version-free — different DSL versions share the same
  rows; renaming is a column/type concern, not a table concern.

Both options are fully independent: you can version without being
multi-tenant, or go multi-tenant without versioning.

## GraphQL with Absinthe

`mix caravela.gen.graphql MyApp.Domains.Library` produces three files —
object types, queries, and mutations — under `lib/<app>_web/schema/`.
All three delegate to the generated context, so authorization, hooks,
and tenant scoping flow through the Absinthe resolvers for free.

Requires the optional Absinthe dependencies in the consumer app:

```elixir
{:absinthe, "~> 1.7"},
{:absinthe_plug, "~> 1.5"},
{:dataloader, "~> 2.0"}
```

Example generated query and mutation (abridged):

```elixir
field :books, list_of(:book) do
  resolve fn _, _, resolution ->
    {:ok, Library.list_books(extract_context(resolution))}
  end
end

field :create_book, :book do
  arg :input, non_null(:book_input)

  resolve fn _, %{input: input}, resolution ->
    Library.create_book(input, extract_context(resolution))
  end
end
```

Input objects exclude the auto-injected `tenant_id` — tenant id comes
from the Absinthe context, not the client.

## 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)
10. Version strings that don't match `~r/^v\d+$/`
11. Manual `tenant_id` fields in a `multi_tenant: true` domain

## 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 Phases 1 – 3

**Phase 1** — `Caravela.Domain` DSL (`entity`, `field`, `relation`), the
compiler with its validation pass, 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`.

**Phase 3** — `multi_tenant: true` option + `version` macro, automatic
`tenant_id` field injection, tenant-scoped reads and writes in the
generated context, version-namespaced modules and routes, composite
tenant indexes in migrations, and Absinthe generation via
`mix caravela.gen.graphql`.

## Roadmap

Later phases add LiveView modules that mount Svelte components via
LiveSvelte, typed Svelte component 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.