The typed client
src/db.generated.js is generated from schema.json and manifest.json. It is the only thing that reads and writes your tables from inside your function, and it exposes one repository per entity.
Regenerate it whenever the schema changes, and never hand-edit it:
revenexx apps generate
The generated client only exposes the operations your manifest grants. With read on an entity you get list, page and get; create, update and delete follow their own grants. An ungranted operation is not generated at all, so the permission boundary is enforced in the types and not only at runtime. See Entity access.
The surface
await db.stock_levels.list({ where, select, order, limit, offset }); // → Row[]
await db.stock_levels.page({ where, select, order, limit, offset }); // → { items, total, limit, offset }
await db.stock_levels.get(id); // → Row | null
await db.stock_levels.create(data); // → Row
await db.stock_levels.update(id, patch); // → Row
await db.stock_levels.delete(id); // → void
The four details that bite:
list()returns an array, not a tuple. Writeconst rows = await db.x.list(...).const [rows] = await db.x.list(...)destructures the first row, not the array.get()returnsnullfor a missing id; it never throws a404. Check the result and thrownotFound()yourself if that is what the route means.orderis a string,'column.direction'—'created_at.desc','priority.asc'. Never an array.page()is how you get a total.list()gives you rows and nothing else.
Every one of these is already scoped to the calling tenant. See Tenant isolation.
The query shape
const rows = await db.device_serials.list({
where: {
status: 'in_stock', // plain value → equality
created_at: { gte: '2026-01-01T00:00:00Z' },
serial: { ilike: 'SN-2026%' },
sold_at: { is: null },
},
select: ['id', 'serial', 'status'],
order: 'created_at.desc',
limit: 50,
offset: 0,
});
| Key | Shape | Notes |
|---|---|---|
where | object, column → value or { operator: value } | A plain value is equality. |
select | array of column names | Projection. Omit for every column. |
order | 'column.asc' or 'column.desc' | One column. |
limit | integer | Always set one on a read that could grow. |
offset | integer | Offset paging. |
The ten operators
eq neq gt gte lt lte like ilike in is
| Operator | Use | Example |
|---|---|---|
eq | Equality. The default for a plain value. | { status: { eq: 'sold' } } |
neq | Inequality. | { status: { neq: 'sold' } } |
gt gte lt lte | Ordered comparison — numbers, timestamps, text. | { on_hand: { gt: 0 } } |
like | Case-sensitive pattern, % and _ wildcards. | { serial: { like: 'SN-%' } } |
ilike | Case-insensitive pattern. | { name: { ilike: '%berlin%' } } |
in | Membership in an array. | { status: { in: ['sold', 'in_service'] } } |
is | Null check. null or not null. | { sold_at: { is: null } } |
What the query surface cannot express
These are limits of the client, not of your database, and they are worth knowing before you design a read around one:
- No
OR. Every condition inwhereisANDed. There is noorkey and no array of alternatives. - No negation of a group.
neqandis not nullnegate one comparison; there is nonotwrapping a set of them. - One filter per column.
whereis an object keyed by column name, so a column appears once. A between —created_at >= a AND created_at < b— cannot be written as two entries oncreated_at. - No joins, no aggregates, no grouping. No
count(*) group by, no sums.page()gives you a total for the whole filter and nothing finer.
When you need something the shape cannot express, you have three honest options, in order of preference:
- Reshape the data. A
statuscolumn, a boolean flag, or ageneratedcolumn often turns anORinto an equality. - Read once and combine in memory. Fetch the candidate rows with the filter you can express and finish the work in JavaScript. This is what a batch endpoint does — one read for each table, then matching in code. A query per item is the classic latency trap.
- Make it a capability with its own logic. If the answer is a computation rather than a filter, that is a custom route, not a query.
Filtering inside a jsonb document
Address a nested key with a JSONB path in the column position:
const rows = await db.device_serials.list({
where: { 'attributes->>sku': { eq: 'WIDGET-1' } },
limit: 50,
});
->> yields text, so compare against a string. A path filter that runs often deserves an index on the same expression, or the read is a scan:
"indexes": [
{ "expression": "((attributes->>'sku'))" }
]
If you filter on a key on every request, promote it to a real column.
select projections
select narrows the columns fetched:
const rows = await db.device_serials.list({
select: ['id', 'serial', 'status'],
limit: 200,
});
Two reasons to use it. A wide row with a large jsonb blob is expensive to move when you needed three columns; and a list capability that returns only the columns its response schema declares cannot accidentally publish a column you added later. Rows come back with only the selected keys, so do not read a field you did not ask for.
Writes
const row = await db.device_serials.create({
serial: 'SN-000123',
product_id: productId,
status: 'in_stock',
});
const updated = await db.device_serials.update(row.id, { status: 'sold', sold_at: new Date().toISOString() });
await db.device_serials.delete(row.id);
create takes the row; the platform fills the injected columns and any SQL default. update takes an id and a partial patch — only the keys you pass are written. delete takes an id and returns nothing.
Constraint violations surface as errors from these calls, and the router maps the common ones to the right status: a unique-index collision becomes 409, a failed check or a bad type becomes 400. See Errors.
Paging over everything
async function* everySerial(db, pageSize = 200) {
for (let offset = 0; ; offset += pageSize) {
const rows = await db.device_serials.list({ order: 'id.asc', limit: pageSize, offset });
yield* rows;
if (rows.length < pageSize) return;
}
}
Always declare an order when you page. Without one, "the next page" is whatever the database returned next, which is not a guarantee.
Next steps
- Adapters — the same client against a mock, a dev tenant, or production.
- CRUD — publishing these operations as routes.
- Pagination and filtering — how the query surface appears to an outside caller.
- App SDK — installing the package.