# .zen syntax

Reference for the Zever schema language: entities, relations, services, RPCs, jobs, schedules, sagas, types, and attributes.

`.zen` files describe services once, in a proto-inspired syntax. The compiler turns the schema into database access, routing, and queue plumbing, never business logic. Format with `zever fmt`, validate with `zever check`, generate with `zever compile` (see the [CLI reference](https://zenta-dev.github.io/zever/reference/cli/)).

## Files and modules

The module name comes from the immediate subdirectory, not the file name: `schema/app/app.zen` is module `app`. A directory such as `schema/v1/iam/` also gives the module a version (`v1`), and a warning flags RPC paths not starting with `/v1/`.

Types in the same module reference each other by bare name, with no import. Cross-module references use the module path, and `zever check-boundaries` reports relations, RPC return types, and permission resources that cross modules. Sagas may reference RPCs across modules by design.

## Declarations

| Declaration | Form | Purpose |
|---|---|---|
| `entity` | `entity Name { fields, relations, indexes }` | Persisted record. |
| `service` | `service Name { rpc ... }` | Group of RPCs. |
| `rpc` | `rpc Name(params) -> Return { options }` | Operation with transport bindings. |
| `enum` | `enum Name { a, b }` | Named closed set of values. |
| `message` | `message Name { fields }` | Non-persisted payload shape. |
| `job` | `job Name(params) { queue: q  retry: ... }` | Background job. |
| `schedule` | `schedule Name { cron: "..."  dispatch: Job() }` | Cron trigger for a job. |
| `saga` | `saga Name { step s { execute: Svc.Rpc } }` | Compensatable RPC sequence. |

Reserved words: `entity`, `service`, `job`, `schedule`, `rpc`, `index`, `enum`, `has_many`, `has_one`, `belongs_to`, `many_to_many`, `message`, `true`, `false`. Type names, option labels (`http`, `auth`, `queue`, ...), HTTP verbs, `saga`, `step`, and `join_table` are contextual, not reserved. Comments start with `//`, and a comment directly above a declaration becomes its doc comment.

## Types

| Type | Notes |
|---|---|
| `uuid`, `string`, `bool`, `bytes`, `json` | Scalars. |
| `int32`, `int64`, `float32`, `float64` | Numeric scalars. |
| `timestamp`, `date` | Time scalars. `datetime` is an alias of `timestamp`. |
| `enum(a, b)` | Inline enum with unique values. Use a named `enum` to share one. |
| `Name` | Reference to an entity or message. |

Append `?` to make a field optional: `bio: string?`.

## Enums and messages

```zen
enum Status { active, inactive }

message LoginRequest {
  email: string @validate(format: "email")
  remember: bool
}

entity Account {
  id: uuid @primary
  status: Status @default(active)
}
```

A named `enum` needs at least one value and unique values, and its name cannot collide with an entity. A `message` carries fields but is never persisted.

## Entities

```zen
entity User {
  id: uuid @primary
  email: string @unique @validate(format: "email")
  created_at: timestamp @default(now())

  has_many orders: Order @foreign_key(user_id)
  index(email, created_at)
}

entity Order {
  id: uuid @primary
  user_id: uuid
  amount_cents: int64

  belongs_to user: User @foreign_key(user_id)
}
```

Declare both sides of a relation with the same `@foreign_key`.

### Field attributes

| Attribute | Applies to | Meaning |
|---|---|---|
| `@primary` | field | Primary key. |
| `@unique` | field | Unique constraint. |
| `@default(x)` | field, message field | Default. `now()` only on `timestamp`; an enum default names a declared value; a literal must match the scalar family. `@default(true)` and `@default(false)` do not parse today because `true` and `false` are reserved words. |
| `@validate(...)` | field, RPC param, message field | Validation rules (below). |
| `@renamed_from("old")` | field | Previous column name, so `zever db migrate` emits a rename not a drop and add. The old name must not still be declared. |

### Validation rules

| Rule | Applies to | Value |
|---|---|---|
| `format` | `string` | `"email"`, `"url"`, or `"uuid"`. |
| `min_len`, `max_len` | `string` | Integer length. |
| `gt`, `gte`, `lt`, `lte` | numeric scalars | Numeric literal. |

Combine rules: `@validate(min_len: 1, max_len: 100)`. `@validate` is rejected on a field or param whose type is an entity or message; put it on that type's fields instead.

Every field of an entity or message used as an RPC parameter must carry at least one applicable `@validate` rule. Return-only types are exempt, as are fields with no applicable rule (`uuid`, `bool`, `timestamp`, `date`, `bytes`, `json`, enums).

### Entity attributes

| Attribute | Meaning |
|---|---|
| `@schema(name)` | Database schema. Defaults to the module name, or `public` when unnamed. |

### Relations

| Keyword | Form | Notes |
|---|---|---|
| `has_many` | `has_many orders: Order @foreign_key(user_id)` | One to many. |
| `has_one` | `has_one profile: Profile @foreign_key(user_id)` | One to one. |
| `belongs_to` | `belongs_to user: User @foreign_key(user_id)` | Owning side. |
| `many_to_many` | `many_to_many tags: Tag { join_table: post_tags }` | No `@foreign_key`. Names the join table. |

| Relation attribute | Values |
|---|---|
| `@foreign_key(field)` | Required on every relation except `many_to_many`. |
| `@on_delete(x)` | `restrict`, `cascade`, or `set_null`. |

### Indexes

`index(a, b)` declares a secondary index over one or more fields. Index attributes follow the closing parenthesis.

## Grammar summary

```text
file     = { entity | service | enum | message | job | schedule | saga }
entity   = "entity" Name { attr } "{" { field | relation | index } "}"
field    = name ":" type [ "?" ] { attr }
relation = ("has_many" | "has_one" | "belongs_to" | "many_to_many") name ":" Name { attr } [ "{" "join_table" ":" name "}" ]
index    = "index" "(" name { "," name } ")" { attr }
service  = "service" Name "{" { rpc } "}"
rpc      = "rpc" Name "(" [ param { "," param } ] ")" "->" Type "{" { option } "}"
param    = name ":" type { attr }
attr     = "@" name [ "(" args ")" ]
option   = label ":" value
```

## Services and RPCs

```zen
service UserService {
  rpc GetUser(id: uuid) -> User {
    http: GET "/v1/users/{id}"
    auth: required
  }

  rpc CreateUser(email: string @validate(format: "email")) -> User {
    http: POST "/v1/users"
    auth: required
    errors: { already_exists }
  }

  rpc ListOrders(user_id: uuid) -> Order {
    http: GET "/v1/orders"
    auth: required
    paginated: true
  }
}
```

| Option | Value | Effect |
|---|---|---|
| `http:` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, then `"/path/{param}"` | HTTP binding. Every `{param}` needs a same-named RPC input. |
| `auth:` | `required` or `none` | Authentication. Every RPC must declare `auth:` or `permission:`; an RPC with neither fails to resolve (secure by default), so write `auth: none` for a public route. Generated gRPC servers are fail-closed: `auth: none` operations are listed with an explicit empty policy, and any unlisted method is denied. |
| `permission:` | `check("action", resource: Entity, owner_field: field)` | Authorization. `resource` must be an entity, `owner_field` one of its fields. |
| `errors:` | `{ code, code("message") }` | Declared error codes (table below). Duplicates are rejected. |
| `paginated:` | `true` or `false` | `true` adds `cursor`/`limit` request fields and a `Response` page (`items`, `next_cursor`) to one RPC, on both HTTP and gRPC. |

Every RPC gets a gRPC transport. Repeating `http:`, `auth:`, `permission:`, or `paginated:` silently keeps the last value.

### Error codes

| Code | HTTP |
|---|---|
| `cancelled` | 499 |
| `unknown`, `internal`, `data_loss` | 500 |
| `invalid_argument`, `failed_precondition`, `out_of_range` | 400 |
| `deadline_exceeded` | 504 |
| `not_found` | 404 |
| `already_exists`, `aborted` | 409 |
| `permission_denied` | 403 |
| `unauthenticated` | 401 |
| `resource_exhausted` | 429 |
| `unimplemented` | 501 |
| `unavailable` | 503 |

## Jobs, schedules, and sagas

```zen
job SendWelcomeEmail(user_id: string) {
  queue: default
  retry: max_attempts(3), backoff(exponential, base: 30s)
}

schedule DailyReport {
  cron: "0 9 * * *"
  dispatch: GenerateDailyReport()
}

saga PlaceOrder {
  step reserve {
    execute: StockService.Reserve
    compensate: StockService.Release
  }
  step charge {
    execute: PaymentService.Charge
    pivot: true
  }
}
```

| Option | Block | Value |
|---|---|---|
| `queue:` | job | Queue name. |
| `retry:` | job | `max_attempts(n)` with `n > 0`, and `backoff(exponential, base: <duration>)`. Exponential is the only backoff kind. |
| `cron:` | schedule | Cron expression. |
| `dispatch:` | schedule | `JobName(args)` naming a declared job. |
| `execute:` | saga step | `Service.Rpc`. Required. |
| `compensate:` | saga step | `Service.Rpc` that undoes the step when a later step fails. |
| `pivot:` | saga step | `true` rolls failures after this step forward instead of compensating. At most one per saga. |

Step names are unique within a saga. Durations use literals such as `30s`.

## Diagnostics

The parser never panics. Malformed input yields best-effort output plus positioned diagnostics, and the formatter refuses input that fails lexing.

| Symptom | Cause |
|---|---|
| Unknown scalar type | Spelling is not in the type table. The compiler suggests the closest scalar. |
| Unresolved relation target | Target missing, in another module without a module path, or the two sides name different `@foreign_key` fields. |
| HTTP param has no RPC input | A `{param}` in the `http:` path has no same-named input. |
| Unknown attribute | Entity allows only `@schema`. Fields allow `@primary`, `@unique`, `@validate`, `@default`, `@renamed_from`. RPC params and message fields allow `@validate` (message fields also `@default`). |

## Related

- [Resolver and IR](https://zenta-dev.github.io/zever/concepts/resolver-ir/) for the compile pipeline and diagnostics.
- [Backends](https://zenta-dev.github.io/zever/concepts/backends/) for generated outputs and golden files.
- [Editor setup](https://zenta-dev.github.io/zever/guides/extend/editor-setup/) for editor support and `make generate`.
