Skip to main content
The expose config on an entity controls the entire VertzQL query surface — which fields appear in responses, which fields can be filtered or sorted, and which relations clients can include. It uses a fractal structure that mirrors the DB query API.

Two layers of field control

Field visibility operates at two levels:
  1. Schema annotations.hidden() and .readOnly() on table columns control the schema-level defaults
  2. expose config — narrows the API surface on top of schema defaults
Schema annotations are the baseline. expose can only narrow further — you cannot expose a .hidden() field through expose.
AnnotationAPI responseFilterableSortable
(none)IncludedYesYes
.hidden()ExcludedNoNo
.readOnly()IncludedYesYes

Basic usage

select is required

When expose is present, select must be provided. This enforces explicit control — if you opt into controlling your API surface, you control all of it:

select: {} for junction entities

An empty select is valid — the entity exposes no own fields, only relations:

Field exposure (select)

select declares which of the entity’s own fields appear in responses. Fields not listed are excluded:

Narrowing-only

select can only narrow what the schema already exposes. Hidden fields cannot be listed:
TypeScript catches this at compile time via PublicColumnKeys<TTable>.

Filter allowlists (allowWhere)

allowWhere declares which fields clients can filter with where clauses. Uses object notation:
If a client filters by a field not in allowWhere, the server returns 400:

Defaults

ConfigDefaultMeaning
allowWhere omittedNo filtering allowedExplicit opt-in
No expose at allAll public fields filterableBackwards compatible

Sort allowlists (allowOrderBy)

allowOrderBy declares which fields can be sorted. Same pattern as allowWhere:
Sort directions are 'asc' or 'desc'.

Relation exposure (include)

Relations are configured inside include. Each relation entry can be:
ValueMeaning
trueExpose with all non-hidden fields
falseDo not expose — clients cannot include this relation
{ select, allowWhere, allowOrderBy, maxLimit, include }Fine-grained control

Fractal structure

The expose config mirrors the DB query API at every level:
DB QueryEntity ExposePurpose
select: { id: true }select: { id: true }Which fields are available
where: { status: 'active' }allowWhere: { status: true }Which fields can be filtered
orderBy: { createdAt: 'desc' }allowOrderBy: { createdAt: true }Which fields can be sorted
include: { comments: { ... } }include: { comments: { ... } }Relation config (recursive)
Same shape, same names, same nesting.

Nested relations

Relations can expose their own relations via nested include:
Client query mirrors the structure:

Relation limits (maxLimit)

maxLimit caps how many related rows per parent:
If a client sends limit: 200 with maxLimit: 50, the server silently clamps to 50.

Field-level access descriptors

Fields in select, allowWhere, and allowOrderBy can use rules.* descriptors for conditional access:

null semantics

When a user doesn’t satisfy a descriptor, the field returns null — not omitted:
Using null is intentional:
  • Signals “this field exists but you can’t see its value”
  • The client always knows the field shape
  • No runtime type surprises — the field is always in the object

Descriptor evaluation

Descriptors are evaluated once per request, not per row. They check user-level attributes (entitlements, roles, FVA), producing a static set of allowed/nulled fields for the entire response.

Descriptor-guarded filters

When a user filters by a descriptor-guarded field they don’t have access to, the error says “field not filterable” — it doesn’t reveal that the field is access-controlled:

Error responses

The server returns 400 Bad Request with clear error messages for every rejection case.

Field not in allowWhere

Field not in allowOrderBy

Field not in select (client select query)

Relation not exposed

Relation field not in select

Nested relation error paths

Errors include the full relation path:

Backwards compatibility

If you don’t provide expose, the entity behaves as before:
  • All non-hidden fields are included in responses
  • All non-hidden fields are filterable and sortable
  • No relations are exposed (you must opt in)
This is a safe default — adding expose is always opt-in.

End-to-end example

1. Define the entity

2. Client query

3. Server validates

  1. where: { status: 'todo' }status is in allowWhere
  2. orderBy: { createdAt: 'desc' }createdAt is in allowOrderBy
  3. include.assignee: trueassignee is exposed ✓
  4. include.comments.orderBycreatedAt is in comments’ allowOrderBy
  5. include.comments.limit: 5 — under maxLimit: 20

4. Response

Note: estimate is null because the user lacks the pm:view-estimates entitlement.

Next steps

Schema

Column types, annotations, and how .hidden() and .readOnly() affect the API surface.

Entities

Entity definitions, access rules, hooks, and custom actions.

Queries

Typed CRUD operations, filter operators, and the DB query API.

Authentication

Access rules, rules.* descriptors, and defineAccess().