@revenexx/app-sdk
v0.3.2
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; 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).
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
