CRUD
Most of an app's API is the same five operations over a table. mountCrud mounts them from one call, so you write custom routes only for the things CRUD cannot express.
const { createApp, mountCrud } = require('@revenexx/app-sdk/router');
const { createDb, ENTITIES } = require('./db.generated');
const app = createApp({ name: 'serials' });
const db = createDb({ adapter: 'runtime', context });
mountCrud(app, db.device_serials, {
path: '/serials',
columns: ENTITIES.device_serials.columns,
});
That mounts:
| Method | Path | Does |
|---|---|---|
GET | /serials | List, with pagination, ordering and per-column filters |
GET | /serials/{id} | Read one, or 404 |
POST | /serials | Create, answering 201 |
PUT | /serials/{id} | Update |
DELETE | /serials/{id} | Delete |
Options
| Option | Purpose |
|---|---|
path | The route prefix, without /v1. Required. |
columns | The entity's column definitions, from ENTITIES.<entity>.columns. Required — this is what tells the list route which query parameters are real columns. |
only | Restrict which of the five routes are mounted. |
parent | Bind a path parameter to a foreign-key column, for a nested resource. |
only
mountCrud(app, db.audit_entries, {
path: '/serials/audit',
columns: ENTITIES.audit_entries.columns,
only: ['list', 'get', 'create'],
});
Names are list, get, create, update, delete. An unmounted route answers 405 if the path matches with another verb, or 404 if nothing matches at all.
only and the entity grant are two different fences and both are worth setting. The grant decides which methods the generated client even has; only decides which of the ones you have become public routes. An append-only ledger declares ["read", "create"] in the manifest and only: ['list', 'get', 'create'] here, and then there is no way — through a bug, a Cockpit action or a partner call — to rewrite history.
parent
mountCrud(app, db.stock_levels, {
path: '/locations/{location_id}/stock',
columns: ENTITIES.stock_levels.columns,
parent: { param: 'location_id', column: 'location_id' },
});
Every list is filtered by the parent, and every create writes it. See Relationships.
Pagination bounds
The list route accepts limit, offset and order.
| Parameter | Default | Bounds |
|---|---|---|
limit | 50 | 1–200. A larger value is clamped, not refused. |
offset | 0 | 0 or greater. |
order | none | column.asc or column.desc. |
Clamping rather than refusing is a deliberate choice: ?limit=100000 gets 200 rows, not a 400, so a naive caller gets a usable answer instead of an error. It also means you cannot ask for everything in one request, and a client that needs everything has to page.
Those bounds are what the capability should declare, so a caller reads them in the contract rather than discovering them:
"parameters": [
{ "name": "limit", "in": "query",
"schema": { "type": "integer", "minimum": 1, "maximum": 200, "example": 50 },
"description": "Page size (default 50, max 200). A larger value is clamped rather than refused." },
{ "name": "offset", "in": "query",
"schema": { "type": "integer", "minimum": 0 },
"description": "Row offset for pagination (default 0)." },
{ "name": "order", "in": "query",
"schema": { "type": "string" },
"description": "Sort as 'column.asc' | 'column.desc', e.g. 'created_at.desc'." }
]
Declare them, or the gateway strips them. An undeclared query parameter is not part of the contract, and a caller who sends ?offset=50 gets page one again. See Pagination and filtering.
Per-column filters
Any column you passed in columns is filterable by name:
curl "https://api.revenexx.com/v1/serials?status=in_stock&product_id=$PRODUCT_ID&limit=50&order=created_at.desc" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
The rules follow the typed client's query surface, because that is what the route builds:
- A
?column=valuepair is equality on that column. - Several pairs are
ANDed together. - One filter per column. There is no
ORand no negation. - A parameter that is not a declared column is ignored, not an error.
Anything richer is a custom route with its own logic, not a filter.
Each filter you intend callers to use needs its own parameters entry too, for the same reason limit does — and because a filter nobody can discover from the contract is a filter nobody uses.
The response envelope
A list answers with the rows, the paging state, and an echo of the filter that produced them:
{
"items": [
{ "id": "…", "serial": "SN-000123", "status": "in_stock", "created_at": "2026-08-01T09:12:44Z" }
],
"page": {
"limit": 50,
"offset": 0,
"total": 128,
"returned": 1,
"hasMore": true
},
"filter": {
"status": "in_stock"
}
}
filter is the filter the server actually applied, not the query string you sent. That is the useful part: a parameter you misspelled is silently dropped by the list route, and the echo is how you find out. If you sent ?statu=in_stock and the echo comes back empty, you have your answer without reading any logs.
get, create and update answer with the row itself, not an envelope. delete answers with { "deleted": true, "id": "…" }.
When to stop using mountCrud
CRUD is right for a table an operator or an integration maintains. It is the wrong shape as soon as a write means more than "store these columns":
- The write has a side effect — a ledger entry, a call to another app, an event that should carry more than the row.
- The operation takes a batch, or answers a question rather than returning a row.
- The input needs guarding beyond what a JSON Schema expresses.
Then write the route yourself with app.post(...), guard the input first, and declare it as its own capability. Mixing both in one app is normal: mountCrud for the table, a handful of custom routes for the verbs.
Next steps
- The router — writing the custom routes CRUD cannot cover.
- Pagination and filtering — the envelope and its parameters in detail.
- Errors — what a failed write answers.
- Capability reference — declaring these routes.