@revenexx/app-sdk
v0.7.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, 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.
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). 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); data-client permission errors become403; anything else is logged and answered as500. - 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).
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.
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
