@revenexx/app-sdk
v1.1.0
Published
Typed data client + HTTP router for revenexx Apps. Swappable adapters (mock | remote | runtime) so the same App code runs against in-memory fixtures locally and the real data plane in production.
Keywords
Readme
@revenexx/app-sdk
The SDK for building revenexx Apps — typed data access and HTTP routing for the revenexx Revenue Cloud app platform.
A revenexx App declares its data model in a schema.json and its identity and
permissions in a manifest.json. The platform provisions the database,
API exposure and tenant isolation from those files; your function code only
needs two things:
- a data client to read and write the App's entities (
@revenexx/app-sdk) - a router to answer the HTTP routes your App exposes (
@revenexx/app-sdk/router)
npm install @revenexx/app-sdkData client
The client gives every entity the same methods — list, page, get,
create, update, updateIf, delete — and the same query shape, independent of where
the data lives. Swap the adapter to move between environments:
| adapter | use it for | infrastructure |
|-----------|-------------------------------------------------------|----------------|
| mock | local development + unit tests against fixtures | none |
| remote | integration checks against a real development tenant | none local |
| runtime | production, inside the deployed function | auto-configured |
const { createClient } = require('@revenexx/app-sdk');
const db = createClient({
adapter: 'mock', // 'remote' | 'runtime'
entities: {
markets: { table: 'acme__shop__markets', pk: 'id' },
},
seed: { markets: [{ id: 'm1', code: 'de', currency: 'EUR' }] },
});
await db.markets.create({ code: 'at', currency: 'EUR' });
const eur = await db.markets.list({ where: { currency: 'EUR' }, order: 'code.asc' });
const page = await db.markets.page({ limit: 20, offset: 0 }); // { items, total }Most Apps don't hand-write the entities map: the revenexx tooling generates a
typed db.generated.js from your App's schema.json + manifest.json, so
createDb({ adapter, ... }) is one import away and every entity is typed.
Queries
One query shape works across all adapters:
await db.markets.list({
where: {
currency: 'EUR', // equality
code: ['de', 'at'], // IN list
created_at: { op: 'gte', value: '2026-01-01' }, // explicit operator
},
select: ['id', 'code'],
order: 'created_at.desc',
limit: 50,
offset: 0,
});Operators: eq, neq, gt, gte, lt, lte, like, ilike, in, is.
Conditional updates
update writes by id, whatever state the row is in. A state transition that
two writers can race — a sweep abandoning a placing order while a re-submit
places it — needs the write to hold only if the row is still where the App
last saw it. updateIf(id, where, patch) is that compare-and-set, in one
request:
const placed = await db.orders.updateIf(id, { status: 'placing' }, { status: 'placed' });
if (!placed) {
// Someone else moved it first (or it is gone). Re-read and decide.
}whereis the querywhereshape above, ANDed with the id. Postgres re-checks it on the locked row, so of two racing writers exactly one wins.- It resolves to the updated row, or
nullwhen no row matched — gone, invisible, or the condition no longer holds. One PATCH cannot tell these apart, and a lost race is an outcome to branch on, not an error. - An empty
where(or a condition leftundefined) is a400: that is an unconditional update, andupdateis the method for it. An empty patch is a400as withupdate. - It needs the
updatepermission. A custom adapter that does not implementupdateIfmakes the call throw — the client does not emulate it as a read followed by a write, because that is the race.
The same holds for a numeric guard: updateIf(id, { committed: seen }, { committed: seen + amount }),
retried on null, is an optimistic budget commit.
Permissions
The methods available per entity follow your App manifest's permissions
declarations. Calling an operation the manifest doesn't grant throws a
descriptive error (and the platform would reject it anyway) — so local tests
catch permission gaps before deploy.
Custom adapters
The adapter is a small interface (list/get/create/update/remove, optional
page and updateIf). Register a new backend before constructing the client:
const { registerAdapter, createClient } = require('@revenexx/app-sdk');
registerAdapter('sqlite', (config) => ({ kind: 'sqlite', /* … */ }));
const db = createClient({ adapter: 'sqlite', entities, file: 'dev.db' });Router
@revenexx/app-sdk/router turns the single function entrypoint into
declarative routes. Path templates use the same {param} syntax as your
manifest's capability routes.
const { createApp, notFound } = require('@revenexx/app-sdk/router');
const { createDb } = require('./db.generated');
const app = createApp({ name: 'markets' });
app.get('/markets/{id}/context', async (c) => {
const db = createDb({ adapter: 'runtime', context: c.ctx });
const market = await db.markets.get(c.params.id);
if (!market) throw notFound();
const locales = await db.locales.list({ where: { market_id: market.id } });
return c.json({ market, locales });
});
module.exports = app.handler();What you get:
- Literal-first matching —
/markets/defaultswins over/markets/{id}, regardless of registration order. - A clean per-request context —
c.params,c.query,c.body(parsed),c.tenant,c.header(name),c.log(msg),c.json(data, status). - Error mapping — throw
HttpError/notFound()/badRequest()/forbidden()/conflict()for explicit statuses; database constraint violations get the status they deserve (see below); a data-client permission refusal is a403(not_permitted); anything else is logged and answered as500 {"error":"internal error","code":"internal_error"}— the thrown message never reaches the caller. - A health route —
GET /answers with the App identity, status and the registered routes (disable withcreateApp({ health: false })). - 404/405 handling — unknown paths list the available routes; known paths
with a wrong method answer
405.
CRUD in one line
mountCrud wires an entity to the standard five REST routes with filtering and
pagination built in:
mountCrud(app, db.markets, { path: '/markets', columns: ENTITIES.markets.columns });
// GET /markets list — ?currency=EUR&limit=50&offset=0&order=code.asc
// POST /markets create
// GET /markets/{id} read
// PUT /markets/{id} update
// DELETE /markets/{id} deleteNested resources scope every operation to their parent:
mountCrud(app, db.locales, {
path: '/markets/{marketId}/locales',
columns: ENTITIES.locales.columns,
parent: { param: 'marketId', column: 'market_id' },
});
// lists filter by market_id, creates inject it, and a locale belonging to a
// different market answers 404 — even if the request body claims otherwise.Options: only: ['list', 'get'] restricts the mounted routes; defaultLimit /
maxLimit tune pagination bounds (defaults 50 / 200).
List query keys are checked. A list accepts the columns in columns, the
reserved keys limit, offset, order (exported as RESERVED_LIST_QUERY_KEYS)
and whatever the App declares in queryParams. Anything else is refused rather
than ignored — an ignored filter answers 200 with the unfiltered list:
GET /customers/segments?organization_id=o1
-> 400 {"error":"unknown list filter 'organization_id' — …","code":"unknown_filter",
"details":{"unknown":["organization_id"],"allowed":["code","id",…,"limit","offset","order"]}}Parameters the App handles itself (a wrapping handler, middleware, a flag it
tolerates) go in queryParams; they are accepted and never become filters:
mountCrud(app, db.accounts, {
path: '/punchout/accounts',
columns: ENTITIES.accounts.columns,
queryParams: ['include_inactive'],
});Without columns, only the reserved and declared keys are accepted. A list
that reads its own parameters can also stay a hand-written route
(only: ['get', 'create', 'update', 'delete'] plus app.get(base, …)).
The mock adapter answers a where on an undeclared column the way PostgREST
does (400 bad_request), instead of an empty result.
A column filter is an exact value. A value that starts with a PostgREST
operator (eq., in., not., …) is refused rather than matched literally —
?sku=in.(a,b) used to go out as sku=eq.in.(a,b) and answer 200 with zero
rows:
GET /products?sku=in.(a,b)
-> 400 {"error":"filter 'sku' carries an operator ('in.') — …","code":"unsupported_filter_operator",
"details":{"parameter":"sku"}}Value lists are not supported by the generic list; a route that needs one reads
its own parameter (queryParams, or a hand-written list).
Constraint violations
A database constraint doing its job is not a server fault. The data adapters
map PostgREST's error to the status the condition deserves and answer with a
stable, generic message — the raw body (which names tables and constraints) is
logged through the function's error() and recorded on the trace, never
returned:
| condition | SQLSTATE | answer | message |
|---|---|---|---|
| duplicate value | 23505 / 23P01 | 409 | a record with this value already exists |
| referenced parent missing (write) | 23503 | 400 | a referenced record does not exist |
| dependent row blocks a delete | 23503 | 409 | this record is still referenced by other records |
| required value missing | 23502 | 400 | a required value is missing |
| check constraint | 23514 | 400 | a value is not allowed here |
| bad uuid/number/timestamp | 22xxx (incl. 22P02) | 400 | a value in the request has the wrong format |
| offset past the last row | PGRST103 | 200 | empty page with the requested limit/offset and the true total |
Responses carry a stable code alongside the message
({"error":"…","code":"unique_violation"}) so clients can branch without
parsing prose. Anything unmapped keeps PostgREST's status when it is a client
error and becomes 500 otherwise — with a generic message either way.
Catching these outside the router: import { isHttpError } from '@revenexx/app-sdk'.
Use isHttpError(err), not instanceof — the package's two entrypoints are
separate bundles, so each carries its own copy of the class.
Writes always answer with a record, or an error
create and update are typed Promise<Row> and now keep that promise —
PostgREST reports a write that touched no row as 200 [], and handing the
missing element back as if it were the record put an undefined into App code
that only blew up at res.json() time, as a 500 with a raw Node message:
| condition | answer | code | message |
|---|---|---|---|
| update with a patch carrying no fields | 400 | empty_update | request body must contain at least one field to update |
| update matched no row (deleted, or invisible under RLS) | 404 | not_found | not found |
| create returned no representation | 500 | empty_representation | the record was created but could not be read back |
The empty-patch rule lives in the data client, not in mountCrud, so it holds
for hand-written route handlers too. It is enforced before the request leaves
the App — an empty patch costs no round trip.
These are HttpErrors: @revenexx/app-sdk/router turns them into the response
automatically. An App that routes itself must honour err.status in its
catch-all (isHttpError(err) ? err.status : 500), or it will report a 400 as a
500 no matter what the SDK raises.
Every refusal carries a code
From 1.0 every refusal the SDK raises — and every one an App raises through the
helpers — carries a stable code beside its message. Branch on the code, never
on the wording:
| code | status | raised by |
|---|---|---|
| not_found | 404 | notFound(), mountCrud for an unknown id or parent, update that matched no row |
| bad_request | 400 | badRequest() |
| forbidden | 403 | forbidden() |
| conflict | 409 | conflict() |
| not_permitted | 403 | an entity-client call the manifest does not grant |
| empty_update | 400 | update / PUT with nothing to update |
| empty_condition | 400 | updateIf with an empty where |
| undefined_condition | 400 | a where entry left undefined, on any read or updateIf |
| unknown_filter, unsupported_filter_operator, invalid_value | 400 | a mounted list's querystring |
| missing_parameter | 400 | a mountCrud route without its path parameter |
| no_route / method_not_allowed | 404 / 405 | the router |
| internal_error | 500 | anything thrown that is not an HttpError |
| unique_violation, exclusion_violation, foreign_key_violation, not_null_violation, check_violation, invalid_value, bad_request, data_error | 409 / 400 / 500 | the data plane (table above) |
A hand-built new HttpError(status, message) carries the code it is given, or none.
Every refusal body also repeats the message as message and the code as type
(from 1.1). Those are the names the platform client (@revenexx/sdk) reads, so its
exception carries the App's reason and code.
Reading another App's refusal
const { refusalOf } = require('@revenexx/app-sdk/router');
try {
await revenexx.call('cost-centers', 'budgets.move', payload);
} catch (err) {
const refusal = refusalOf(err); // { status, code, message, details, app } | null
if (refusal?.code === 'no_active_budget') return c.json({ ... }, 409);
throw err; // not a refusal: a crash or the far side being down
}refusalOf reads this App's own HttpError and another App's refusal caught from
the platform client alike. It returns null for anything that is not a refusal.
Who is acting
c.tenant says whose data a request is for. c.actor says whose hand is on it.
app.post('/procurement/pending-approvals/{id}/approve', async (c) => {
const approval = await db.pending_approvals.get(c.params.id);
// Compare two names, never two absences: a caller with no contact has none,
// and an approval nobody is named on has none either, so `!==` alone would
// let the two nulls agree.
if (!c.actor.contact || approval.approver_contact_id !== c.actor.contact) {
throw forbidden('this approval is not yours');
}
// …
});Note what this does not do: gate on authenticated(). Read the next section
before you write that line.
| field | is |
|---|---|
| user / email | the human the gateway authenticated, or null. null today |
| contact / organization | the customers contact they were resolved to, or null |
| permissions, holds(p) | what the tenant's role provider grants that contact |
| trust | authenticated, asserted or none |
| plane | contact, operator or system |
| authenticated() | shorthand for trust === 'authenticated', and false for every caller today |
One plane produces anybody today, and it is asserted. A storefront call is
made by a server holding the tenant API key on behalf of a customer: it asserts
the person, the gateway resolves the name through the tenant's role provider, and
trust is asserted. That is enough to stop a malicious end user and never a
compromised BFF, the same boundary X-Revenexx-Market sits on, and the platform
accepts it deliberately everywhere.
authenticated is not currently reachable, and you must not build authority on
it. The value exists in the contract, c.actor reads it correctly, and the
gateway does not produce it: a bearer token there is deliberately equivalent to an
API key, so it proves the tenant rather than a person. The population on that
plane is mostly machines — a theme site renders every request on the tenant's
system service user, schedules fan out on the same subject, an operator working
in a customer tenant acts on its operator service user — and none of them is
somebody. authenticated() therefore answers false for every caller, which is
the honest answer rather than a gap to work around. An app that refuses
asserted refuses everyone.
plane follows from the same fact. contact is somebody the gateway
resolved; system is nobody at all, which is a schedule, a webhook, a service
token or any caller that named no one. operator would be an authenticated human
with no contact and is likewise not produced today. Treating "no contact
resolved" as "must be the platform" reads an unknown customer as an
administrator, which is the mistake the field exists to prevent.
Every field comes from headers the gateway sets itself and strips from anything a caller sends, so none of this can be talked up from outside. Never take the acting person from the context token's claims — those describe the calling system, which in a theme site is the theme's service user — and never from the request body, which is the caller's own word about itself.
App settings
An App declares the knobs a merchant may turn in settings.json; the Cockpit
renders them and Console stores the tenant's choices. c.settings() is how the
App reads them back at runtime:
const app = createApp({
name: 'orders',
settings: { defaults: { auto_reserve: true, reservation_minutes: 30 } },
});
app.post('/orders', async (c) => {
const s = await c.settings();
if (s.auto_reserve) await reserve(c.body, s.reservation_minutes);
// …
});defaults mirrors the defaults in your settings.json. They type the resolved
object and are what the App runs on if the settings service cannot be reached —
they never override a value the merchant actually set.
Outside a handler, build the reader yourself. Create it once, at module scope: the cache lives on it.
const { createSettings } = require('@revenexx/app-sdk');
const settings = createSettings({ app: 'orders', defaults: { auto_reserve: true } });
const s = await settings.get(c); // c = RouteContext, FnContext or { headers }
const full = await settings.load(c); // { settings, masked, market, resolved }Resolution
Values are resolved by the gateway (GET /v1/settings/apps/{app}?market=), not
here. Per key, highest wins:
| | source |
|---|---|
| 1 | per-market override — only for settings declared "scope": "market", and only when the request carries a market |
| 2 | the stored tenant value |
| 3 | the default in settings.json |
| 4 | the defaults you passed here — the offline fallback, and only where 1–3 produced nothing |
The market comes off the request's X-Revenexx-Market, so a market-scoped
setting resolves for the market the call is for without being asked. Override
it per read with c.settings({ market: 'fr' }).
Settings declared sensitive are never returned; their keys are listed in
masked on load(), so an App can tell "not configured" from "not for you".
Failing open, and staying cheap
A settings read sits on the hot path of every request, so it must cost nothing and break nothing:
- Fail open. An unreachable gateway, a timeout (2s), a missing tenant, an
App that declares no settings — you get the last values that did resolve, or
your declared defaults.
settings()never throws. PassonError(wire it to the function'serror()) to keep the fallback visible. - Cache. Per
(tenant, app, market), 60s by default, with concurrent reads sharing one in-flight request — a warm read is aMaplookup. The gateway keeps its own 30s cache and drops it when values change, so a Cockpit edit takes effect within about a minute. Tune withttlMsorREVENEXX_SETTINGS_TTL_MS; a failed read is retried after 5s, not 60.
Testing your App
Handlers are plain functions over a context object, so tests don't need any
infrastructure: build the app with a mock-adapter client, call
app.handler() with a fake { req, res }, and assert on the captured JSON.
const handler = app.handler();
await handler({
req: { method: 'GET', path: '/markets', headers: {}, query: {} },
res: { json: (data, status = 200) => ({ data, status }) },
log: console.log,
});License
MIT
