Schema reference
The Keel schema is a DSL used to define data models, actions, permissions, and other components that are transformed into APIs and project infrastructure. It allows you to specify the structure and behavior of your application's data and operations in a concise and readable manner.
A Keel schema (one or many .keel files) is composed of declarations:
Each declaration begins with a keyword (model, enum, message, role, api, job) followed by a name and a block defining its contents.
keyword EntityName {
// ..
}Comments in Keel DSL use the // syntax for single-line comments.
Models
Models represent data structures in your application. They can have fields, actions, and permissions.
model ModelName {
// Sections: fields, actions
}Fields
Fields define the data properties of a model.
fields {
fieldName FieldType [modifiers] [attributes]
}fieldName: The name of the field.FieldType: The data type of the field.- Modifiers:
[]: Indicates the field is an array (repeated).?: Indicates the field is optional.
- Attributes: Additional metadata for the field, defined using attributes.
Field types
Built-in scalar types:
| Field Type | Description |
|---|---|
ID | A unique identifier (KSUID) |
Text | A string |
Number | An integer |
Decimal | A decimal number |
Date | A date without time (ISO 8601 format) |
Duration | A time interval (ISO 8601 format) |
Timestamp | A UTC timestamp |
Boolean | A boolean value (true or false) |
Secret | An encrypted secret |
Password | A hashed password |
Markdown | Rich text in Markdown format |
Vector | A vector type |
File | A file input, supplied as a data URL |
You can also use other models or enums as field types.
Example
fields {
name Text
rating Number?
tags Text[]
books Book[]
}Actions
Actions define operations that can be performed on the model.
Standard actions
Standard actions are actions where Keel handles the implementations. The functionality can be extended using hooks.
actions {
actionType actionName(readFields) [with (writeFields)] [attributes]
}| Parameter | Description |
|---|---|
actionType | The type of action: get, create, update, delete, or list |
actionName | Globally unique name of the action (e.g., createPost, updateUser) |
readFields | Comma-separated list of model fields or custom parameter used for selecting the entry |
writeFields | Comma-separated list of model fields or custom parameter to write |
attributes | Additional controls for the behavior of the action |
Supported attributes
| Attribute | get | list | create | update | delete |
|---|---|---|---|---|---|
@embed | ✓ | ✓ | |||
@permission | ✓ | ✓ | ✓ | ✓ | ✓ |
@function | ✓ | ✓ | ✓ | ✓ | ✓ |
@where | ✓ | ✓ | ✓ | ✓ | ✓ |
@orderBy | ✓ | ||||
@sortable | ✓ | ||||
@set | ✓ | ✓ | |||
@validate | ✓ | ✓ | ✓ |
Custom parameter
For write actions, you can specify custom fields that are not part of the model with the syntax fieldName: FieldType. These fields must then be used as part of a @set or @where:
actions {
update updatePost(id) with (customInput: Text) {
@set(title = customInput)
}
}Custom actions
Custom actions are actions where you define the implementation. Either as a read function that returns data or a write function that modifies data.
actions {
actionType actionName(readFields) returns (returnTypes) [attributes]
}| Parameter | Description |
|---|---|
actionType | The type of action: read, write |
actionName | Globally unique name of the action (e.g., createPost, updateUser) |
readFields | Comma-separated list of model fields or message to use as inputs |
returnTypes | Message to use as output |
attributes | Additional controls for the behavior of the action |
Supported attributes
| Attribute | read | write |
|---|---|---|
@permission | ✓ | ✓ |
N.B. @permission expressions can't use row-based data for custom actions. For row based permission checked, handle the logic within the function.
Example
actions {
create createAuthor(name) @function
get getAuthor(id)
list listAuthors() {
@sortable(firstName, surname)
@orderBy(firstName: asc, surname: desc)
}
read getExternalAuthor(extId: Text) returns (GetAuthorResponse)
write processAuthor(id) returns (GetAuthorResponse)
}
message GetAuthorResponse {
name Text
}
message GetAuthorResponse {
authorId ID
}Attributes
Attributes provide metadata and additional behavior to models, fields, and actions. They are denoted using the @ symbol, followed by the attribute name and optional arguments.
@attributeName(arguments)Attributes can be applied to fields, actions, or models.
| Attribute | Fields | Actions | Models | Jobs |
|---|---|---|---|---|
| @permission | ✓ | ✓ | ✓ | |
| @unique | ✓ | ✓ | ||
| @relation | ✓ | |||
| @default | ✓ | |||
| @function | ✓ | |||
| @orderBy | ✓ | |||
| @sortable | ✓ | |||
| @embed | ✓ | |||
| @where | ✓ | |||
| @set | ✓ | |||
| @validate | ✓ | ✓ | ||
| @on | ✓ | |||
| @schedule | ✓ |
@permission
Defines access control for actions or models. It specifies which roles have access and can include expressions for conditional permissions.
Syntax:
@permission(
roles: [Role1, Role2, ...],
actions: [actionType1, actionType2, ...],
expression: <condition>
)roles: A list of roles granted access.actions: A list of action types the permission applies to (get,list,create,update,delete).expression: An optional logical condition that must be met for the permission to be granted.
Multiple @permission attributes can be applied to the same action or model and will be evaluated as a logical or.
If a permission is defined on an individual action, it will replace any permissions defined for that action type on the model.
Usage:
@permission(
roles: [Admin],
actions: [create, update, delete]
)Row-based permissions:
@permission(
expression: employee.isActive == true && employee.department == "HR",
actions: [update]
)Note: In row-based permissions, you can reference model fields and compare them to static values or context variables.
For more information see permissions.
@unique
Ensures that the field's value is unique across all records in the model. This attribute is applied to fields within a model or on the modal for compound unique constraints.
Usage:
fields {
username Text {
@unique
}
}modal User {
fields {
username Text
email Text
}
@unique([username, email])
}Permissions specificity
model Post {
fields {
name Text
author Author
owner Identity
}
actions {
update updatePost(id) with (name)
update updatePostAuthor(id) with (author) {
// This will override the model level permission
@permission(expression: post.owner == ctx.identity)
}
}
@permission(
expression: post.author.identity == ctx.identity,
actions: [update]
)
}@function
Marks a standard action as a function, this exposes action hooks where you can extend the functionality.
Usage:
actions {
create createAuthor(name) @function
}@orderBy
The @orderBy attribute specifies default ordering for list actions. You can define multiple fields and specify the sort direction (asc for ascending or desc for descending).
Usage:
actions {
list listItems() {
@orderBy(price: asc, createdAt: desc)
}
}@sortable
The @sortable attribute specifies fields that can be used for sorting in list actions. Clients can sort results by these fields when making queries.
Usage:
actions {
list listProducts() {
@sortable(name, price, rating)
}
}@on
Defines event subscribers for models based on the action type.
Syntax:
@on([actionType], functionName)Run keel generate to scaffold the subscriber function.
Usage:
model User {
@on([create], sendWelcomeEmail)
@on([create, update, delete], syncUsersWithExternalSystem)
}@embed
Embedding specifies related models to include in the JSON and RPC api responses for get or list actions. This is useful for fetching associated data in a single query.
Can be used multiple times to embed multiple fields and can also embed multiple levels deep.
Usage:
actions {
get getOrder(id) {
@embed(customer)
@embed(items)
}
list listOrders() {
@embed(customer)
@embed(customer.addresses)
}
}@relation
If you need multiple relationships between the same two models, then you will need to explicitly specify to which fields each of the relationships join with.
This is done using the @relation attribute.
Usage:
model Post {
fields {
writtenBy Author @relation(written)
reviewedBy Author @relation(reviewed)
}
}
model Author {
fields {
written Post[]
reviewed Post[]
}
}Note that @relation is only valid on the has one side of the relationship.
@default
Sets a default value for a field when a new record is created.
Using @default without arguments will default to empty value of the field type.
| Data Type | Empty Value |
|---|---|
| Text | "" (empty string) |
| Number | 0 |
| Decimal | 0 |
| Boolean | false |
| Timestamp | Current timestamp |
| Date | Current date |
| ID | Auto-generated KSUID |
Usage:
fields {
isActive Boolean @default(true)
createdAt DateTime @default
}@where
Adds conditions to actions to filter data based on specified criteria. Applicable to get, list, update, and delete actions.
Usage:
actions {
list listActiveUsers() {
@where(user.isActive == true)
}
update deactivateUser(id) {
@where(user.isActive == true) // Will 404 if user is not active
@set(user.isActive = false)
}
}@set
Assigns values to fields during create or update actions. You can set fields to static values, input variables, or expressions.
Usage:
actions {
create createPost(title) {
@set(post.status = "draft")
@set(post.createdAt = ctx.now)
@set(post.author = ctx.identity)
}
update publishPost(id) {
@set(post.status = "published")
@set(post.publishedAt = ctx.now)
}
}Note: You can also use @set with custom input fields not defined in the model.
Example with custom input:
actions {
create createEvent() with (startTime: Timestamp, duration: Number) {
@set(event.startTime = startTime)
@set(event.endTime = startTime + duration * durationUnit)
}
}@validate
Expresses a business rule that must hold for a write to proceed. On a built-in create, update or delete action it guards writes made through that action. On a model it declares an invariant every record must always satisfy, which the database enforces for every writer. The syntax is the same in both places, and the two forms are described in turn below.
Syntax:
@validate(<boolean expression>)
@validate(<boolean expression>, "<error message>")The expression must resolve to Boolean. The message is optional and must be a non-empty string literal. When you omit it, a failure reports the expression source text instead.
Usage:
model Order {
fields {
customer Customer
quantity Number
status OrderStatus
trackingNo Number?
}
actions {
create createOrder() with (customer.id, quantity) {
@validate(quantity > 0, "quantity must be greater than zero")
}
update dispatch(id) with (trackingNo) {
@set(order.status = OrderStatus.Dispatched)
@validate(order.status == OrderStatus.Packed, "order must be packed")
}
delete deleteOrder(id) {
@validate(order.status != OrderStatus.Dispatched, "dispatched orders cannot be deleted")
}
}
}You can put more than one @validate on an action. All of them must hold for the write to happen, and a failing request reports every rule that failed rather than stopping at the first.
@validate is not supported on get or list actions. Use @where to filter what a read returns. It is also not supported alongside @function, since a function implements its own behaviour and can throw errors.BadRequest instead. See Error handling.
What a rule can reference
| A rule can reference | create | update / delete |
|---|---|---|
| Action inputs | ✓ | ✓ |
ctx, environment variables, secrets | ✓ | ✓ |
| Enums and literals | ✓ | ✓ |
The record (order.status) | ✓ | |
Relations (order.customer.isActive) | ✓ | |
| Computed fields | ✓ |
A create rule cannot reference the record, because the record does not exist when the rule runs. Referencing it is a schema error, so reference the inputs directly.
Rules see the record before the write
An update or delete rule reads the record as it was when the request arrived, the same way @where does. In the dispatch example above, order.status is the status the order had before the action ran, even though the same action uses @set to change it. That is what makes the rule a guard on the transition rather than a check on the result.
Null handling in an action rule
An action's rules are evaluated by the database, and a rule whose expression does not come out true fails. A comparison against null is not true, so the rule fails rather than being skipped. This covers an optional input the caller omitted, a nullable field that is not set, and an absent relation. A model-level rule does the opposite, as described under model-level rules below, so check this when moving a rule between the two.
// Rejects a request that omits trackingNo, because null > 0 is not true.
@validate(trackingNo > 0)To write a rule that only applies when the value is present, guard it with IF:
// Passes when trackingNo is omitted, and applies normally when it is supplied.
@validate(IF(trackingNo > 0, trackingNo > 10, true), "tracking number must be above ten")What the caller sees
A failing rule returns an HTTP 400 listing every failure, in the order the rules are declared in the schema:
{
"code": "ERR_INVALID_INPUT",
"message": "one or more validation rules failed",
"data": {
"errors": [{ "error": "order must be packed" }]
}
}This is the same envelope the request schema validator uses. On the JSON-RPC API the payload is under error.detail, and on the GraphQL API it is under extensions.
Permission checks run before validation, so a caller who is not allowed to perform the action gets ERR_PERMISSION_DENIED and no rule is evaluated. A missing record is reported as ERR_RECORD_NOT_FOUND. Validation and the write happen in one transaction, so a failed rule leaves nothing behind, including in nested creates, where every level is gated.
Action rules apply at the action boundary
An action's rules guard writes made through that action. Writes made with the Model API from a custom function, subscriber, flow or task, raw SQL through useDatabase, and seed data do not pass through the action, so its rules do not apply to them. Use an action rule for a rule about a particular request, and a model-level rule for anything the stored data must always satisfy.
Model-level rules
@validate on a model block, alongside @permission and @unique, declares an invariant every record of that model must always satisfy:
model Order {
fields {
quantity Number
customer Customer
items Item[]
}
@validate(order.quantity > 0, "quantity must be greater than zero")
@validate(order.customer.isActive, "the customer must be active")
@validate(SUM(order.items.amount) <= 1000, "the order total is too large")
}The attribute takes the same arguments as the action-level form. What differs is where the rule is enforced: the migration compiles each model rule into database triggers, so it holds for every writer, including all of the paths an action rule does not reach. It is not valid on a field, nor on any declaration other than a model or one of its actions.
A model rule reads the record as the write leaves it, so it describes the state the record must end up in rather than a transition. Because the rule is run by the write itself, and the write has no request behind it, a model rule cannot reference ctx, environment variables or secrets. Referencing ctx is a schema error that points you at the action-level form. Nor can a model rule read action inputs: a field must be reached through the record, so write order.quantity rather than quantity. Otherwise a model rule can use everything the expression language offers, including relations, computed fields, aggregates over a to-many relationship, quantifiers, and date functions.
Two rules on the same model with both the same expression and the same message are rejected. The same expression carrying a different message is a different rule and is accepted.
What the caller sees for a model rule
A write that leaves the record breaking a model rule returns HTTP 409:
{
"code": "ERR_CONFLICT",
"message": "a validation rule failed",
"data": { "errors": [{ "error": "quantity must be greater than zero" }] }
}ERR_CONFLICT rather than the action-level ERR_INVALID_INPUT, because the input was well formed and it is the record's state that conflicts. The data.errors shape is the same, so a client can read both the same way. Exactly one rule is reported, since the first failure aborts the statement, whereas an action's rules are evaluated together and report every failure. A rule with no message reports its expression source.
A model rule is re-checked when a relation changes
A rule that reads a relation is checked again when that relation is written, not only when the record itself is written. SUM(order.items.amount) <= 1000 therefore refuses an Item insert that would take its order over the cap, and order.customer.isActive refuses deactivating a customer who still holds orders. The write that is refused is the one to the related record, and the message belongs to the rule that refused it.
The statement is the unit of evaluation
A model rule is evaluated once per statement, across every row that statement wrote. A multi-row INSERT containing one violating row is refused whole, and a bulk UPDATE that leaves any row violating leaves every row unchanged. This is what lets a nested create satisfy a rule that aggregates over the record's children, since the rule sees the parent and its children together.
Because these are ordinary triggers, they hold at every statement boundary and SET CONSTRAINTS ALL DEFERRED does not defer them. A presence-style rule such as COUNT(order.items) > 0 is satisfied by a nested create, which writes the parent and its children in one statement, but not by a writer that creates the parent in one statement and its children in another. They are also not enforced under session_replication_role = replica, which disables triggers. Trigger-maintained computed fields have the same exposure.
Null handling in a model rule
A model rule treats null the opposite way to an action rule. Only a rule the database can judge to be false counts as a violation, so a rule that evaluates to null passes and the write goes ahead.
// On a model, this holds for an order with no trackingNo, because null > 0 is null.
// The same expression on an action rejects that order, because null is not true there.
@validate(order.trackingNo > 0)This covers a nullable field that is not set and a rule reaching through a relation that is absent. A quantifier over no rows is satisfied for the same reason. To make a model rule reject a record whose value is missing, test for it, for example order.trackingNo != null && order.trackingNo > 0.
Adding a model rule to existing data
The migration checks the rows already in the table and refuses to deploy if any of them violate the new rule:
cannot enforce @validate on Order: 2 existing row(s) violate it
DETAIL: rule: quantity must be greater than zero
HINT: correct or remove the offending rows, then deploy againCorrect or remove those rows and deploy again.
@schedule
Schedules a job and flows to run at specific intervals. The argument is a cron expression or natural language description that defines the schedule. More information on the scheduling expressions can be found here.
Usage:
job EmailNewCustomerReport {
@schedule("every monday at 9am")
}Enums
Enums define a set of named constant values.
enum EnumName {
Value1
Value2
// ...
}Example
enum Planet {
Mercury
Venus
Earth
Mars
}Messages
Messages define custom input and output types for actions. They are especially useful when defining custom functions.
message MessageName {
fieldName FieldType [modifiers]
// ...
}Messages can be nested to define more complex structures.
message MessageName {
fieldName FieldType
messageField OtherMessage
// ..
}
message OtherMessage {
fieldName FieldType
// ...
}Example
message MyInput {
id ID
}
message MyOutput {
name Text?
}
message Book {
name Text
}
message AuthorAndBooks {
name Text
books Book[]
}Roles
Roles define access permissions based on domains or specific emails.
role RoleName {
domains {
"example.com"
"anotherdomain.com"
}
emails {
"user@example.com"
"admin@example.com"
}
}Example
role Admin {
domains {
"keel.xyz"
"keel.zyx"
}
emails {
"adam@keel.xyz"
"adam@keel.zyx"
}
}Jobs
Jobs define background tasks that run on a schedule. See the Jobs page for full documentation.
job JobName {
@schedule(<cron_expression>)
}APIs
APIs define how models and their actions are exposed. By default there is an api called API which contains all the models.
Additional APIs can be defined or the default API can be redefined and manually controlled.
api ApiName {
models {
ModelName {
// Optionally filter the actions
actions {
actionName
// ...
}
}
// ...
}
}Example
api Web {
models {
Customer
Order
Product {
actions {
getProduct
listProducts
}
}
}
}
api Admin {
models {
Customer
Order
Product
InventoryItem
StockLocations
}
}