0.480 (09 Sep 2026)

9 September, 2026

This release adds an asynchronous upload path for File inputs, so a client can send a large file straight to storage instead of squeezing it through the action request. It also tightens two schema checks that were letting broken schemas through, and lets @unique apply to Date fields.

Async file uploads for large files

A File input used to have only one form: a base64 data URL sent inline in the action request. That capped a real file at around 4.5MB, because the encoded payload had to fit inside the 6MB request body limit, and it made every client buffer and encode the whole file before it could call the action.

Create and update actions now expose an upload route alongside them. You ask the action for an upload URL for one of its File inputs, PUT the bytes straight to storage, then call the action with the returned key where the data URL used to go:

// 1. Mint an upload for the `image` input of `createProduct`
// POST /api/json/createProduct/upload/image
{
  "filename": "front.jpg",
  "contentType": "image/jpeg",
  "size": 4812331
}
 
// Response
{
  "key": "2ZkH1xBenRVdYI8iwao1hYUtt",
  "url": "https://.../uploads/2ZkH1xBenRVdYI8iwao1hYUtt?X-Amz-...",
  "headers": {
    "Content-Type": "image/jpeg",
    "Content-Length": "4812331"
  },
  "expiresAt": "2026-09-09T13:15:00Z",
  "urlExpiresAt": "2026-09-09T11:30:00Z"
}

All three request fields are required. size is in bytes, and filename must be non-empty and at most 255 bytes.

Send the bytes to url with a PUT, using exactly the headers that came back. Both of those headers are signed, so storage refuses the upload if the content type or the length differs from what you declared. Then call the action with the key:

// 2. PUT the bytes to `url` with the returned headers
// 3. Call the action with the key
// POST /api/json/createProduct
{
  "name": "Trail Runner GTX",
  "image": "2ZkH1xBenRVdYI8iwao1hYUtt"
}

The two expiry timestamps do different jobs. urlExpiresAt is when the presigned URL stops accepting the PUT, and expiresAt is when the key can no longer be attached to the action at all. So you have a short window to upload and a longer one to finish calling the action.

Data URLs still work exactly as before, and a File[] input can mix the two forms in one request, so you can move a client over one field at a time.

Constraining what a field accepts

Two optional field attributes let you say what a File field will take on the upload path:

model Product {
    fields {
        name Text
        image File @accepts(["image/*", "application/pdf"]) @maxSize("50MB")
        manuals File[] @accepts(["application/pdf"]) @maxSize("1.5GiB")
    }
 
    actions {
        create createProduct() with (name, image, manuals) {
            @permission(roles: [Catalog])
        }
    }
}

@accepts takes a list of MIME patterns, each either an exact type like application/pdf or a type with a wildcard subtype like image/*. Matching ignores case and any parameters on the content type, so image/jpeg; charset=binary matches image/jpeg. */* and a bare * are rejected — an empty list is how you accept anything.

@maxSize takes a size with a unit. B, KB, MB and GB are decimal (1KB is 1000 bytes) and KiB, MiB and GiB are binary. Units are case-insensitive, whitespace between the number and the unit is optional, and fractional values are allowed. TB is not a unit, and the largest size you can declare is 5GB.

A File field with neither attribute accepts any content type up to the platform default of 100MB, so an existing schema gets the upload route with no changes at all.

Both attributes are checked when an upload is minted and again when it is attached, against the real content type and length of the object you uploaded rather than the values you declared. They are checks on the upload path only: a file sent as an inline data URL is still stored without them, so treat them as constraints on uploads rather than as validation on the field. If you need a rule that holds however the file arrives, that is not something these attributes give you yet.

Who can upload

There is no new permission syntax. The right to mint an upload comes from the target action's own @permission rules. A rule that resolves without looking at a record — a role, ctx.isAuthenticated, expression: true — decides outright. A rule that needs a record lets an authenticated caller mint, and the full check still runs when the upload is attached. Anonymous callers only mint against actions whose rules provably allow them, so a public create action gives you a public upload scope and you can see that in the schema.

An upload key can be attached once, and only through the action and field it was minted for. If a caller has too many uploads outstanding, minting returns HTTP 429 with the new ERR_RATE_LIMITED code. The route also returns 404 for an unknown action or field, or a field that is not a File, 403 for a caller the rules deny, and 400 when the content type or size you declared fails the field's constraints.

A few things this first pass does not cover. Flows keep their existing presigned-URL callback for file inputs. Only top-level inputs are covered, so a File nested inside a message input of a function action still takes a data URL. There are no image variants or resizing. The GraphQL API and RPC get no equivalent route, so a client on those APIs mints through the JSON route and passes the key along in its own request, and the generated client is unchanged.

One thing to know if you generate code from the spec: a File input in the JSON schema no longer carries format: data-url, and now describes both accepted forms instead. See Files and the JSON API for the full picture.

Two schema checks that will fail schemas that used to validate

Both of the checks below reject schemas that keel validate accepted before, so a working project can fail to build after you upgrade. In each case the schema was already broken and the check is what makes that visible.

Generated input message names must be unique. A nested input builds its message name from the action name and the relationship path, so create createOrder() with (customer.name) generates CreateOrderCustomerInput. That silently collided with the root input message of an action named createOrderCustomer, which generates the same name, and one of the two won:

model Order {
    fields {
        customer Customer
        reference Text
    }
 
    actions {
        // Both of these want CreateOrderCustomerInput
        create createOrder() with (reference, customer.name)
        create createOrderCustomer() with (reference)
    }
}

That is now a build error:

generated action input message 'CreateOrderCustomerInput' conflicts with the message generated for action 'createOrder'
Rename one of the actions or relationship paths so their generated input message names are unique

The names are readable enough that a collision like this is easy to hit by accident, so if you upgrade and see this, renaming one of the two actions is the fix.

ANY and ALL must operate over a to-many relationship. A quantifier reads a collection of related records, and its predicate now has to name a to-many relationship path. A repeated scalar field is refused, and so is a to-one relationship:

ANY requires a to-many relationship, but 'invoice.tags' is not to-many
ANY requires a to-many relationship, but 'order.customer' is not to-many

So is a predicate with no single shared path, either because it mixes two relationship paths or because it is rooted at ctx rather than at a model:

ALL predicate field references must share a to-many relationship path

This can break an existing @permission rule or @computed field. Where you were quantifying over a Text[] field, or over ctx.identity, you need a different expression rather than a rename.

@unique on Date fields

@unique now works on a Date field, and the migration creates a real unique constraint for it:

model Invoice {
    fields {
        reference Text @unique
        periodEnd Date @unique
    }
}

Timestamp is still excluded, since a microsecond-accurate value is not a useful thing to make unique. See Unique fields for the full list of types a unique constraint can cover.

Fixes and Improvements

  • CLI: keel client is no longer a full-screen terminal UI. It streams plain output, so it behaves correctly in CI and anywhere else without an interactive terminal. It now validates keelconfig.yaml as well as your schema, and exits non-zero on failure outside watch mode; in watch mode it reports the failure and keeps watching, so a run that starts invalid recovers once you fix it. keel validate's success message changed too, and now says whether tool configuration was checked. Anything that parses the output of either command will need updating. The contents of the generated client are unchanged.
  • Functions: creating a record with nested has-many children through the Model API now returns the parent as it stands after the children are inserted. A column maintained by a trigger, or a computed field that reads those children, is no longer stale in the response.
  • Authentication: a failing afterAuthentication hook no longer turns an unsuccessful token grant into a 500. The original error status is preserved and the hook failure is logged instead. A hook that fails after a successful grant still returns 500.

For any issues or feedback, please contact us at help@keel.so.

Thank you for using Keel!