SchemaForge generates full CRUD endpoints from .schema files, but most
real applications need more than simple get-by-id lookups. The query interface
lets you filter, sort, and paginate entities using either query-string parameters
on GET requests or structured JSON bodies on POST requests.
Both approaches produce the same response format and apply the same access control rules. Use whichever fits your client best: query strings for simple lookups from a browser or curl, JSON bodies for complex nested logic built programmatically.
- Endpoints
- Response Format
- Pagination
- Field Projection
- Sorting
- Filtering with Query Parameters (GET)
- Filtering with JSON Body (POST)
- Filter Operators
- Logical Operators
- Type Coercion
- Dotted Field Paths
- Access Control
- Examples
GET /schemas/{schema}/entities
Accepts filter, sort, and pagination as query-string parameters.
POST /schemas/{schema}/entities/query
Content-Type: application/json
Accepts a JSON body with filter, sort, limit, and offset fields.
Both endpoints require read access to the schema and return the same
ListEntitiesResponse shape.
{
"entities": [
{
"id": "01J...",
"schema": "Contact",
"fields": {
"name": "Alice",
"age": 30,
"status": "Active"
}
}
],
"count": 1,
"total_count": 42
}| Field | Type | Description |
|---|---|---|
entities |
array of objects | Readable matching entities after pagination |
count |
integer | Number of entities in this page |
total_count |
integer or null | Readable matching entities before pagination (when requested) |
Each entity object contains:
| Field | Type | Description |
|---|---|---|
id |
string | Entity identifier |
schema |
string | Schema name |
fields |
object | Field names mapped to their values |
Both endpoints accept limit and offset.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | none | Maximum results to return |
offset |
integer | 0 | Number of readable results to skip |
GET example:
GET /schemas/Contact/entities?limit=25&offset=50
POST example:
{
"limit": 25,
"offset": 50
}Record policies and Cedar checks run before offset, limit, and total counting. Denied rows do not contribute to totals or create gaps in pages. Exact totals use a storage count when PostgreSQL certifies the generated Read policy and stored row shape. Other cases evaluate all matching candidates; use count=false (GET) or "count": false (POST) to stop after the requested readable page is filled. Anonymous responses omit totals. See authorized entity pagination for backend costs and concurrent-write limits.
Use total_count from the response to calculate page counts:
total_pages = ceil(total_count / limit)
current_page = floor(offset / limit) + 1
By default, responses include all entity fields. Use the fields parameter to
return only the fields you need, reducing payload size and focusing on relevant
data.
Pass a comma-separated list of field names:
?fields=name,email,status
Pass an array of field names in the request body:
{
"fields": ["name", "email", "status"]
}Field projection is optional. When omitted, all fields are returned. The id
and schema properties are always included in the response regardless of
projection.
Invalid field names return a 400 error listing the unknown fields.
GET example:
GET /schemas/Contact/entities?fields=name,email&limit=25
POST example:
{
"fields": ["name", "email"],
"filter": { "op": "eq", "field": "status", "value": "Active" },
"limit": 25
}Response with projection:
{
"entities": [
{
"id": "01J...",
"schema": "Contact",
"fields": {
"name": "Alice",
"email": "alice@example.com"
}
}
],
"count": 1,
"total_count": 42
}Field projection applies after access control filtering. If a field is
restricted by @field_access, it is removed before projection is applied.
Requesting a restricted field by name does not bypass access control — the
field is simply absent from the result.
Pass a sort query parameter with comma-separated fields. Two styles are
supported and can be mixed:
Prefix style (Django-inspired):
| Prefix | Direction |
|---|---|
- |
Descending |
+ |
Ascending |
| none | Ascending |
?sort=-age,name
Sort by age descending, then name ascending.
Colon style:
?sort=age:desc,name:asc
Same result as above.
Pass a sort array of objects:
{
"sort": [
{ "field": "age", "order": "desc" },
{ "field": "name", "order": "asc" }
]
}| Field | Type | Default | Description |
|---|---|---|---|
field |
string | — | Field name (supports dotted paths) |
order |
string | "asc" |
"asc" or "desc" |
Filters are expressed as query parameters using double-underscore operator suffixes:
?field__operator=value
A bare field name without an operator suffix defaults to equality:
?name=Alice # same as ?name__eq=Alice
Multiple filter parameters are combined with AND logic:
?name__startswith=A&age__gt=25&status=Active
This matches entities where name starts with "A" and age is greater than 25 and status equals "Active".
| Suffix | Operator | Example |
|---|---|---|
__eq or none |
Equals | ?name=Alice |
__ne |
Not equals | ?status__ne=Archived |
__gt |
Greater than | ?age__gt=25 |
__gte |
Greater than or equal | ?age__gte=18 |
__lt |
Less than | ?age__lt=65 |
__lte |
Less than or equal | ?score__lte=100 |
__contains |
Substring match | ?name__contains=ice |
__startswith |
Prefix match | ?email__startswith=admin |
__in |
Set membership (comma-sep) | ?status__in=Active,Pending |
The __in operator accepts comma-separated values. Each value is individually
type-coerced based on the field's schema type.
The names limit, offset, sort, fields, count, and resolve are
reserved for pagination, sorting, projection, total-count opt-out, and
relation-display opt-out. They cannot be used as filter field names.
The POST endpoint accepts a filter object in the request body. Filters are
JSON objects tagged by an "op" field:
{
"filter": {
"op": "gt",
"field": "age",
"value": 25
}
}This format supports the full set of filter operators and can express nested logical conditions that query-string filters cannot.
{ "op": "eq", "field": "status", "value": "Active" }
{ "op": "ne", "field": "status", "value": "Archived" }
{ "op": "gt", "field": "age", "value": 25 }
{ "op": "gte", "field": "age", "value": 18 }
{ "op": "lt", "field": "age", "value": 65 }
{ "op": "lte", "field": "score", "value": 100 }{ "op": "contains", "field": "name", "value": "ice" }
{ "op": "startswith", "field": "email", "value": "admin" }{ "op": "in", "field": "status", "values": ["Active", "Pending"] }{
"op": "and",
"filters": [
{ "op": "gte", "field": "age", "value": 18 },
{ "op": "lt", "field": "age", "value": 65 }
]
}{
"op": "or",
"filters": [
{ "op": "eq", "field": "status", "value": "Active" },
{ "op": "eq", "field": "status", "value": "Pending" }
]
}{
"op": "not",
"filter": { "op": "eq", "field": "status", "value": "Archived" }
}Logical operators nest arbitrarily, so you can express any boolean combination.
| Operator | Description | Value type |
|---|---|---|
eq |
Exact equality | any |
ne |
Not equal | any |
gt |
Greater than | numeric, datetime |
gte |
Greater than or equal | numeric, datetime |
lt |
Less than | numeric, datetime |
lte |
Less than or equal | numeric, datetime |
contains |
Substring match (case-sensitive) | string |
startswith |
Prefix match (case-sensitive) | string |
in |
Value is in the provided set | array of any |
| Operator | Description | Structure |
|---|---|---|
and |
All sub-filters must match | { "op": "and", "filters": [...] } |
or |
At least one sub-filter must match | { "op": "or", "filters": [...] } |
not |
Sub-filter must not match | { "op": "not", "filter": {...} } |
Note the difference: and and or take a "filters" array, while not takes
a single "filter" object.
These are only available through the POST JSON body. GET query-string filters are always AND-combined.
Filter values are automatically coerced based on the field's type as defined in the schema:
| Schema field type | Coercion behavior |
|---|---|
Integer |
Parsed as 64-bit integer |
Float |
Parsed as 64-bit float |
Boolean |
"true" / "1" → true, "false" / "0" → false |
DateTime |
Parsed as ISO 8601 UTC (e.g. 2024-01-15T09:30:00Z) |
Enum |
Accepted as string, validated against schema variants |
Text / RichText |
Kept as string |
For GET requests, all values arrive as strings and are coerced using these rules. For POST requests, JSON native types (numbers, booleans) are used directly, with string values coerced when the schema type requires it.
Invalid coercions return a 400 error with a description of the type mismatch.
Field names support dotted notation for traversing relations:
?sort=-company.name
{ "op": "eq", "field": "company.industry", "value": "Technology" }This lets you filter or sort by fields on related entities. The path follows
the relation chain defined in your schema: company.industry means "the
industry field on the entity referenced by the company relation".
Query results are filtered through multiple access control layers:
-
Schema-level access — the caller must have read permission on the schema. Unauthenticated requests are rejected if the schema requires authentication.
-
Tenant scope — in multi-tenant configurations, a tenant filter is automatically injected into every query. Callers only see entities belonging to their tenant.
-
Record-level authorization — every returned row is authorized individually against the Cedar policy set after the query executes, so a custom policy can withhold specific records. Note that
@ownerdoes not restrict visibility: it establishes who created a record and stops anyone else changing it, while who may see a record is decided by@access(read: [...])alone. A schema that wants owner-only reads must say so with a custom Cedar policy, guarded by!context.resource_is_placeholderso the schema preflight is not denied along with the records — see Custom policies and authorization context. -
Field-level filtering — fields with read restrictions are stripped from the response. The entity still appears, but restricted fields are omitted.
All of this happens transparently. You do not need to add tenant or ownership filters to your queries manually.
curl 'http://localhost:3000/schemas/Contact/entities?limit=10&offset=0'curl 'http://localhost:3000/schemas/Contact/entities?fields=name,email&limit=25'curl -X POST 'http://localhost:3000/schemas/Contact/entities/query' \
-H 'Content-Type: application/json' \
-d '{
"fields": ["name", "status"],
"filter": { "op": "eq", "field": "status", "value": "Active" },
"limit": 25
}'curl 'http://localhost:3000/schemas/Contact/entities?status=Active&sort=name'curl 'http://localhost:3000/schemas/Contact/entities?age__gte=18&age__lt=65&sort=-age'curl 'http://localhost:3000/schemas/Contact/entities?status__in=Active,Pending&limit=50'curl 'http://localhost:3000/schemas/Contact/entities?name__contains=smith&sort=name'curl -X POST 'http://localhost:3000/schemas/Contact/entities/query' \
-H 'Content-Type: application/json' \
-d '{
"filter": {
"op": "and",
"filters": [
{
"op": "or",
"filters": [
{ "op": "eq", "field": "status", "value": "Active" },
{ "op": "eq", "field": "status", "value": "Pending" }
]
},
{ "op": "gte", "field": "age", "value": 18 },
{ "op": "startswith", "field": "name", "value": "A" }
]
},
"sort": [
{ "field": "name", "order": "asc" }
],
"limit": 25,
"offset": 0
}'curl -X POST 'http://localhost:3000/schemas/Contact/entities/query' \
-H 'Content-Type: application/json' \
-d '{
"filter": {
"op": "not",
"filter": { "op": "eq", "field": "status", "value": "Archived" }
}
}'curl 'http://localhost:3000/schemas/Contact/entities?sort=-company.name&limit=20'In JSON filters, eq with value: null matches absent fields and explicit null
values. ne with value: null matches populated fields. A non-null ne also
includes null fields, so eq and ne partition the records for a given value.
List results and total_count use the same predicates on all three backends.
Paged entity queries default to id ASC. Explicit sorts receive an id ASC
tie-breaker unless they already specify id; an explicit id DESC is preserved.
The JSON sort key accepts both order and direction, with asc or desc.
The implicit id field supports filters, including gt for id-range paging.
This makes pages deterministic for an unchanged dataset. Concurrent inserts,
deletes, or sort-field edits can still move records between offset pages.