The typed client

The generated data client over your app's own tables — list, page, get, create, update and delete, the query shape, the ten filter operators, and what the query surface deliberately cannot express.

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:

Terminal
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

Data client 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. Write const rows = await db.x.list(...). const [rows] = await db.x.list(...) destructures the first row, not the array.
  • get() returns null for a missing id; it never throws a 404. Check the result and throw notFound() yourself if that is what the route means.
  • order is 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

src/main.js
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,
});
KeyShapeNotes
whereobject, column → value or { operator: value }A plain value is equality.
selectarray of column namesProjection. Omit for every column.
order'column.asc' or 'column.desc'One column.
limitintegerAlways set one on a read that could grow.
offsetintegerOffset paging.

The ten operators

text
eq    neq   gt    gte   lt    lte   like   ilike   in    is
OperatorUseExample
eqEquality. The default for a plain value.{ status: { eq: 'sold' } }
neqInequality.{ status: { neq: 'sold' } }
gt gte lt lteOrdered comparison — numbers, timestamps, text.{ on_hand: { gt: 0 } }
likeCase-sensitive pattern, % and _ wildcards.{ serial: { like: 'SN-%' } }
ilikeCase-insensitive pattern.{ name: { ilike: '%berlin%' } }
inMembership in an array.{ status: { in: ['sold', 'in_service'] } }
isNull 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 in where is ANDed. There is no or key and no array of alternatives.
  • No negation of a group. neq and is not null negate one comparison; there is no not wrapping a set of them.
  • One filter per column. where is 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 on created_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:

  1. Reshape the data. A status column, a boolean flag, or a generated column often turns an OR into an equality.
  2. 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.
  3. 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:

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

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

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

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

src/main.js
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.
Was this page helpful?