0.476 (02 Sep 2026)

2 September, 2026

This release adds unions, a new kind of schema declaration that assembles one read-only fact set out of several models, so a question answered by rows in different tables can be asked in a single query.

Unions

A union declares a shape and then maps each contributing model's rows into it with a from arm. Keel compiles the arms into a database view, and the union serves get and list actions and a read-only Model API on top of it.

A projected stock position is the usual example: committed movements, plus promises from confirmed purchase orders, less demand that has not been allocated yet. Three models, one fact set.

schema.keel
union StockOutlook {
    fields {
        kind MovementKind
        item Item
        location StockLocation?
        occursOn Date
        quantity Decimal
    }
 
    from StockEntry {
        kind @computed(MovementKind.Committed)
        item @computed(stockEntry.item)
        location @computed(stockEntry.location)
        occursOn @computed(stockEntry.occurredAt)
        quantity @computed(stockEntry.quantity)
    }
 
    from PurchaseOrderLine {
        @where(purchaseOrderLine.confirmed)
        kind @computed(MovementKind.PlannedIn)
        item @computed(purchaseOrderLine.item)
        location @computed(purchaseOrderLine.destination)
        occursOn @computed(purchaseOrderLine.expectedOn)
        quantity @computed(purchaseOrderLine.outstandingQuantity)
    }
 
    from SalesOrderLine {
        @where(salesOrderLine.status == SoStatus.Open)
        kind @computed(MovementKind.PlannedOut)
        item @computed(salesOrderLine.item)
        occursOn @computed(salesOrderLine.shipBy)
        quantity @computed(0 - salesOrderLine.unallocatedQuantity)
    }
 
    actions {
        get getMovement(id)
        list listMovements(item.id?, kind?, occursOn?)
    }
 
    @permission(expression: true)
}

Each arm maps every field the union declares, with one @computed expression rooted at that arm's source model. An arm's @where narrows which of its rows become facts, and a field marked optional can be left unmapped by an arm, which puts those rows in the field's null group. Mapping every field explicitly means nothing is matched up by name, so leaving a required field unmapped is a build error rather than a surprise at read time.

Every row carries three fields you do not declare. id is the source row's primary key, passed straight through, which is what a get looks up by. sourceModel and sourceArm say which arm produced the row, so a caller can navigate on to the underlying record. There is no createdAt or updatedAt, because a fact is derived at read time and has no life of its own.

Where one model contributes more than one kind of fact, draw two arms from it and name them with as. A stock transfer is one row that produces an outbound movement on one date and an inbound movement on another:

schema.keel
union StockTransfers {
    fields {
        item Item
        location StockLocation
        occursOn Date
        quantity Decimal
    }
 
    from TransferLine as Outbound {
        item @computed(transferLine.item)
        location @computed(transferLine.fromLocation)
        occursOn @computed(transferLine.departsOn)
        quantity @computed(0 - transferLine.quantity)
    }
 
    from TransferLine as Inbound {
        item @computed(transferLine.item)
        location @computed(transferLine.toLocation)
        occursOn @computed(transferLine.arrivesOn)
        quantity @computed(transferLine.quantity)
    }
 
    actions {
        list listLegs()
    }
 
    @permission(expression: true)
}

Both legs come back from listLegs, told apart by sourceArm. They share an id, since that is the transfer's own primary key, which is why a union whose model feeds several arms cannot declare a get action: there would be nothing unique to get by.

In code, a union appears on models in the generated SDK with a read-only API of findOne, findMany and where:

const facts = await models.stockOutlook.findMany({
  where: { item: { id: { equals: widget.id } } },
});
 
const planned = await models.stockOutlook
  .where({ kind: { equals: MovementKind.PlannedIn } })
  .findMany();

There is no create, update or delete on that API and no write action type in the schema, so a write is a compile error or a build error rather than a runtime surprise. Postgres refuses writes to the view as well.

@permission is mandatory as soon as a union declares actions. A union does not inherit its source models' permission rules, so without one it would be a read surface with nothing guarding it. A rule with no actions: argument grants every action type the union can serve, since a union only reads and there is no write to leave ungated.

A union's actions join your default API automatically, so they are served over the JSON API and become queries in your GraphQL API. Because the union is a view rather than a copy, the fact set is always current: update a source row and the next read reflects it, with no refresh step and no mirror to keep in sync.

See the Unions documentation for the full details, including the naming rules, the attributes each part of a union accepts, how the view is built and maintained, and the current limitations.

Fixes and Improvements

  • CLI: keel format no longer moves comments around or throws them away. An end-of-line comment such as sku Text @unique // stock keeping unit used to come back on a line of its own, and any comment following the last declaration in a file was deleted outright, so a commented-out model at the bottom of a schema was silently removed and a file containing nothing but comments formatted to an empty file. Comments sitting just before a closing ) or ] were lost too. One case is still imperfect: a comment on the last entry of a multi-line action input list moves onto the closing-bracket line rather than staying put. See keel format.

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

Thank you for using Keel!