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 TypeDescription
IDA unique identifier (KSUID)
TextA string
NumberAn integer
DecimalA decimal number
DateA date without time (ISO 8601 format)
DurationA time interval (ISO 8601 format)
TimestampA UTC timestamp
BooleanA boolean value (true or false)
SecretAn encrypted secret
PasswordA hashed password
MarkdownRich text in Markdown format
VectorA vector type
FileA 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]
}
ParameterDescription
actionTypeThe type of action: get, create, update, delete, or list
actionNameGlobally unique name of the action (e.g., createPost, updateUser)
readFieldsComma-separated list of model fields or custom parameter used for selecting the entry
writeFieldsComma-separated list of model fields or custom parameter to write
attributesAdditional controls for the behavior of the action

Supported attributes

Attributegetlistcreateupdatedelete
@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]
}
ParameterDescription
actionTypeThe type of action: read, write
actionNameGlobally unique name of the action (e.g., createPost, updateUser)
readFieldsComma-separated list of model fields or message to use as inputs
returnTypesMessage to use as output
attributesAdditional controls for the behavior of the action

Supported attributes

Attributereadwrite
@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.

AttributeFieldsActionsModelsJobs
@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 TypeEmpty Value
Text"" (empty string)
Number0
Decimal0
Booleanfalse
TimestampCurrent timestamp
DateCurrent date
IDAuto-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 referencecreateupdate / 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 again

Correct 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
    }
}