CRUD

mountCrud — the five standard routes over one entity, restricting them with only, nesting them under a parent, the pagination bounds, per-column filters and the filter echo.

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.

src/main.js
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:

MethodPathDoes
GET/serialsList, with pagination, ordering and per-column filters
GET/serials/{id}Read one, or 404
POST/serialsCreate, answering 201
PUT/serials/{id}Update
DELETE/serials/{id}Delete

Options

OptionPurpose
pathThe route prefix, without /v1. Required.
columnsThe entity's column definitions, from ENTITIES.<entity>.columns. Required — this is what tells the list route which query parameters are real columns.
onlyRestrict which of the five routes are mounted.
parentBind a path parameter to a foreign-key column, for a nested resource.

only

src/main.js
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

src/main.js
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.

ParameterDefaultBounds
limit501200. A larger value is clamped, not refused.
offset00 or greater.
ordernonecolumn.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:

manifest.capabilities.json
"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:

Request
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=value pair is equality on that column.
  • Several pairs are ANDed together.
  • One filter per column. There is no OR and 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:

Response
{
  "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

Was this page helpful?